Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input

# Conflicts:
#	apps/cli/README.md
#	apps/cli/src/web.ts
#	apps/web/tests/smoke-fixture.e2e.ts
#	docs/architecture.i18n.yaml
#	docs/module-graph.md
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl
#	packages/client/connection/src/client/api.ts
#	packages/client/connection/src/client/fixture.ts
#	packages/client/runtime/README.md
#	packages/client/runtime/src/client/index.ts
#	packages/client/runtime/src/client/sessions/conversation.ts
#	packages/client/runtime/src/client/sessions/service.ts
#	packages/client/runtime/src/client/sessions/session.ts
#	packages/client/ui-conversation/src/client/apply.ts
#	packages/client/ui-conversation/src/client/contract/slots.ts
#	packages/client/ui-conversation/src/client/index.ts
#	packages/client/ui-conversation/src/client/service.ts
#	packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
#	packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx
#	packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
#	packages/client/ui-conversation/src/client/stores.ts
#	packages/client/ui-conversation/tests/apply-inject.spec.tsx
#	packages/client/ui-conversation/tests/service-orchestration.spec.ts
#	packages/client/ui-conversation/tests/skeleton-branches.spec.tsx
#	packages/client/ui-conversation/tests/skeleton.spec.tsx
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/host/apiproxy/package.json
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/host/apiproxy/src/api/sessions.schema.ts
#	packages/host/runtime/package.json
#	packages/host/runtime/src/boot.ts
#	packages/host/runtime/tests/host-runtime.spec.ts
#	packages/host/runtime/tsconfig.json
#	packages/host/webserver/README.md
#	packages/host/webserver/src/index.ts
#	packages/host/webserver/tests/webserver.spec.ts
#	packages/llm/llm-pi-ai/tests/convert.spec.ts
#	packages/ui/acp/src/codec.ts
#	packages/ui/acp/tests/codec.spec.ts
#	pnpm-lock.yaml
This commit is contained in:
Yichen Jiang
2026-07-25 22:35:22 +08:00
1264 changed files with 37694 additions and 29428 deletions

View File

@@ -0,0 +1,981 @@
/**
* Host-side ApiProxy implementation. Signature discipline: unary takes the
* narrow RpcRequest<P> and echoes request.rpcId on the RpcResponse<T>.
*/
import { randomUUID } from 'node:crypto'
import { mkdir, stat } from 'node:fs/promises'
import { join } from 'node:path'
import type { Context } from 'cordis'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import { AttachmentError } from '@deepseek-ai/dsh-attachment-local'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment-local'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
import {
workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId, WorkspaceNameConflictError,
} from '@deepseek-ai/dsh-workspace'
// Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters).
import type {} from '@deepseek-ai/dsh-tools'
import type {
ApiProxy, HistoryEntry, HostFrame, MuxFrame, PromptContentPart, QuestionResponsePayload, SessionSummary, ToolEventView,
WorkspaceId, WorkspaceView,
} from './api/index.ts'
import { questionResponsePayloadSchema } from './api/questions.schema.ts'
import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from './api/rpc.ts'
import { RpcId } from './api/rpc.ts'
import type {
AskUserQuestionAnswer, AskUserQuestionItem, AskUserQuestionRequest,
} from '@deepseek-ai/dsh-user-interaction'
import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction'
/** Page size when history is called without maxMessages. */
const DEFAULT_MAX_MESSAGES = 50
/** Surface message event types (the pagination counting unit). */
const MESSAGE_TYPES = new Set(['user/message', 'assistant/message', 'steering/message'])
function decodeBase64(data: string): Uint8Array {
if (data.length === 0 || data.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(data)) {
throw new AttachmentError('Image upload is not canonical base64.', 'INVALID_IMAGE_BASE64')
}
const decoded = Buffer.from(data, 'base64')
if (decoded.toString('base64') !== data) {
throw new AttachmentError('Image upload is not canonical base64.', 'INVALID_IMAGE_BASE64')
}
return new Uint8Array(decoded)
}
async function durablePromptContent(ctx: Context, content: readonly PromptContentPart[]): Promise<ContentBlock[]> {
const limits = ctx.attachments.imageLimits
const prepared = content.map(part => part.type === 'text'
? part
: { part, data: decodeBase64(part.data) })
const images = prepared.filter((part): part is Extract<typeof part, { data: Uint8Array }> => 'data' in part)
if (images.length > limits.maxImagesPerMessage) {
throw new AttachmentError('Prompt exceeds the configured image-count limit.', 'TOO_MANY_IMAGES')
}
const totalBytes = images.reduce((sum, image) => sum + image.data.byteLength, 0)
if (totalBytes > limits.maxMessageImageBytes) {
throw new AttachmentError('Prompt exceeds the configured aggregate image-byte limit.', 'IMAGES_TOO_LARGE')
}
// Validate the complete batch before persisting any member: the store has no
// garbage collection, so one malformed image must not leave the batch's
// valid members as published objects no message event will ever reference.
for (const image of images) {
ctx.attachments.validateImage({
data: image.data,
mediaType: image.part.mediaType,
...image.part.name === undefined ? {} : { name: image.part.name },
})
}
return Promise.all(prepared.map(async (item): Promise<ContentBlock> => {
if (!('data' in item)) return { type: 'text', text: item.text }
const attachment = await ctx.attachments.saveImage({
data: item.data,
mediaType: item.part.mediaType,
...item.part.name === undefined ? {} : { name: item.part.name },
})
return { type: 'image', attachment }
}))
}
function imageInContent(content: unknown, attachmentId: string): 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 { type?: unknown; attachment?: unknown; content?: unknown }
if (block.type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) {
const ref = block.attachment as ImageAttachmentRef
if (String(ref.attachmentId) === attachmentId) return ref
}
if (block.type === 'tool-result') {
const nested = imageInContent(block.content, attachmentId)
if (nested !== undefined) return nested
}
}
return undefined
}
function referencedImage(events: readonly SessionEvent[], attachmentId: string): ImageAttachmentRef | undefined {
for (const event of events) {
const data = event.data as { content?: unknown; chunk?: { type?: unknown; block?: unknown } }
const direct = imageInContent(data.content, attachmentId)
if (direct !== undefined) return direct
if (event.type === 'assistant/chunk' && data.chunk?.type === 'block-end') {
const streamed = imageInContent([data.chunk.block], attachmentId)
if (streamed !== undefined) return streamed
}
}
return undefined
}
/**
* Message-boundary pagination: count maxMessages surface messages backwards from
* the window tail; the cut is the starting seq of the oldest message group
* (chunks group via sourceEventSeqs — never cut mid-message). The tail page
* naturally includes the in-progress partial.
*/
function paginate(
events: readonly SessionEvent[],
beforeSeq: number | undefined,
maxMessages: number,
): { events: SessionEvent[]; hasMore: boolean } {
const window = beforeSeq === undefined ? [...events] : events.filter(event => event.seq < beforeSeq)
let count = 0
let cut = 0
for (let i = window.length - 1; i >= 0; i--) {
const event = window[i] as SessionEvent
if (!MESSAGE_TYPES.has(event.type)) continue
count++
const sources = (event as { sourceEventSeqs?: number[] }).sourceEventSeqs
const groupStart = sources !== undefined && sources.length > 0 ? Math.min(event.seq, ...sources) : event.seq
if (count >= maxMessages) {
cut = groupStart
break
}
}
const page = window.filter(event => event.seq >= cut)
return { events: page, hasMore: cut > 0 }
}
/** Wrap an ok result echoing the request's rpcId. */
function ok<T>(request: RpcRequest<unknown>, value: T): RpcResponse<T> {
return { rpcId: request.rpcId, result: { ok: true, value } }
}
/** Wrap an error result echoing the request's rpcId. */
function err<T>(request: RpcRequest<unknown>, error: RpcError): RpcResponse<T> {
return { rpcId: request.rpcId, result: { ok: false, error } }
}
/** Simple async queue: core callbacks push, the AsyncIterable pulls; abort/return cleans up. */
class FrameQueue<F> {
private buffer: F[] = []
private waiter: (() => void) | undefined
private done = false
push(item: F): void {
if (this.done) return
this.buffer.push(item)
this.waiter?.()
}
end(): void {
this.done = true
this.waiter?.()
}
async *iterate(signal: AbortSignal, cleanup: () => void): AsyncGenerator<F> {
const onAbort = (): void => { this.end() }
signal.addEventListener('abort', onAbort, { once: true })
try {
while (true) {
while (this.buffer.length > 0) yield this.buffer.shift() as F
if (this.done || signal.aborted) return
await new Promise<void>((resolve) => { this.waiter = resolve })
this.waiter = undefined
}
} finally {
signal.removeEventListener('abort', onAbort)
cleanup()
}
}
}
/**
* Server-side frame mint: pure pushes get a fresh rpcId per frame (stable ids
* for answerable frames belong to the approval/question registry, absent in
* this minimal version).
*/
function frame<F>(payload: F): RpcRequest<F> {
return { rpcId: RpcId(randomUUID()), payload }
}
type SessionTitleFrame = Extract<MuxFrame, { type: 'session/title' }>
/** Project the latest durable title without exposing title-generation policy. */
function titleFrame(session: Session): SessionTitleFrame | undefined {
const title = foldSessionTitle(session.events)
if (title === undefined) return undefined
return {
type: 'session/title',
sessionId: session.id,
title: title.title,
eventSeq: title.eventSeq,
updatedAt: title.updatedAt,
}
}
/** Queue the subscription baseline followed by its optional title snapshot. */
function subscribeSession(queue: FrameQueue<RpcRequest<MuxFrame>>, session: Session): void {
queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 }))
const title = titleFrame(session)
if (title !== undefined) queue.push(frame(title))
}
/** SessionSummary projection for attached (in-memory) sessions. */
function summarize(session: Session, running: boolean): SessionSummary {
return {
sessionId: session.id,
updatedAt: session.events.at(-1)?.time ?? session.header.createdAt,
running,
...session.header.parentSession === undefined ? {} : { parentSessionId: session.header.parentSession },
...session.header.cwd === undefined ? {} : { cwd: session.header.cwd },
}
}
/**
* SessionSummary projection for cold (persisted, unattached) sessions.
* updatedAt is the log file's mtime; backends without a per-session file
* (locate() undefined) fall back to the header's createdAt.
*/
async function summarizeCold(persistence: SessionPersistence, meta: SessionHeader): Promise<SessionSummary> {
let updatedAt = meta.createdAt
const location = persistence.locate(meta)
if (location !== undefined) {
try {
updatedAt = (await stat(location.path)).mtimeMs
} catch {
// The log vanished between list() and stat() (concurrent cleanup); createdAt stands in.
}
}
return {
sessionId: meta.id,
updatedAt,
running: false,
...meta.parentSession === undefined ? {} : { parentSessionId: meta.parentSession },
/* v8 ignore next -- the empty arm needs a cwd-less meta, but list()
filters those out (legacy logs are not served); the conditional mirrors
summarize() shape. */
...meta.cwd === undefined ? {} : { cwd: meta.cwd },
}
}
/** Resolved Host routing and project-directory defaults consumed by the API implementation. */
export interface ApiProxyDefaults {
provider: string
model: string
/** Default project directory for new sessions whose create request carries no cwd. */
cwd: string
/** Parent directory for name-created workspaces. */
workspaceRoot: string
}
/** The tool/call payload fields the presenter path reads. */
interface ToolCallData { callId: string; name: string; arguments: string }
/** The tool/result payload fields the presenter path reads. */
interface ToolResultData { callId: string; content: ContentBlock[]; isError: boolean; meta?: JsonValue }
/** One host-owned question wait, addressed by the stable server-request id. */
interface PendingQuestion {
rpcId: RpcId
sessionId: SessionId
questions: AskUserQuestionItem[]
resolve: (answer: AskUserQuestionAnswer) => void
reject: (error: UserInteractionError) => void
signal?: AbortSignal
onAbort?: () => void
}
/** Validate one answer batch against the exact question request it resolves. */
function matchesQuestions(payload: QuestionResponsePayload, pending: PendingQuestion): boolean {
if (payload.sessionId !== pending.sessionId) return false
const answers = payload.answer.answers
if (answers.length !== pending.questions.length) return false
return answers.every((answer, index) => {
const question = pending.questions[index] as AskUserQuestionItem
if (answer.id !== question.id) return false
if (new Set(answer.selected).size !== answer.selected.length) return false
const custom = answer.custom?.trim()
if (custom !== undefined && custom === '') return false
if (custom !== undefined && answer.selected.length > 0) return false
if (question.multiSelect !== true && answer.selected.length > 1) return false
const labels = new Set(question.options?.map(option => option.label) ?? [])
return answer.selected.every(label => labels.has(label))
})
}
/**
* Compute the render intent for a tool/call or tool/result event through the
* presenters registered at this moment; every other event type gets none. A
* result's presenter needs its call's parsed args — `argsFor` supplies them
* (live: the per-session call table; history: an in-page backscan), returning
* undefined when the pairing is unavailable (e.g. the call fell off the page),
* which soft-falls to no view. Presenter or JSON.parse throws also soft-fall:
* the client's documented default (generic JSON card) covers every miss.
*/
function viewFor(ctx: Context, event: SessionEvent, argsFor: (callId: string) => unknown): ToolEventView | undefined {
try {
if (event.type === 'tool/call') {
const { name, arguments: raw } = event.data as ToolCallData
const view = ctx.tools.get(name)?.presentCall?.(JSON.parse(raw))
return view === undefined ? undefined : { for: 'call', view }
}
if (event.type === 'tool/result') {
const { callId, content, isError, meta } = event.data as ToolResultData
const call = argsFor(callId) as { name: string; args: unknown } | undefined
if (call === undefined) return undefined
const view = ctx.tools.get(call.name)?.presentResult?.(call.args, { content, isError, ...meta === undefined ? {} : { meta } })
return view === undefined ? undefined : { for: 'result', view }
}
} catch (error: unknown) {
// A throwing presenter (or unparseable arguments) must not break delivery;
// the event still ships, just without a view.
console.error(`api-proxy: presenter failed for ${event.type}, falling back to generic: ${String(error)}`)
}
return undefined
}
/**
* Resolve a tool/result's call pairing by scanning a window of events backwards
* for the matching tool/call. Used by the history path (the page is the
* window — a cross-page pairing soft-falls to no view) and by live-path table
* misses after a reconnect-eviction.
*/
function backscanArgs(events: readonly SessionEvent[], callId: string): { name: string; args: unknown } | undefined {
for (let i = events.length - 1; i >= 0; i--) {
const event = events[i] as SessionEvent
if (event.type !== 'tool/call') continue
const data = event.data as ToolCallData
if (data.callId !== callId) continue
try {
return { name: data.name, args: JSON.parse(data.arguments) }
} catch {
// Unparseable stored arguments: same soft-fall as a live parse failure.
return undefined
}
}
return undefined
}
/**
* Thrown by the cold-resume path when the id names no servable session
* (absent from the store, or a pre-project legacy log without a cwd).
*/
class SessionNotFound extends Error {}
/** Requested identity already belongs to a session with another project cwd. */
class SessionCwdConflict extends Error {
constructor(
readonly sessionId: SessionId,
readonly requestedCwd: string,
readonly existingCwd: string | undefined,
) {
super(
`session "${sessionId}" already exists with cwd ${JSON.stringify(existingCwd)}; `
+ `requested ${JSON.stringify(requestedCwd)}`,
)
}
}
/** Host failed before the registry could adopt a name-created directory. */
class WorkspaceDirectoryCreationError extends Error {}
/** Wire projection of one workspace entity (the workspace.* value row). */
function workspaceView(workspace: Workspace): WorkspaceView {
return {
workspaceId: workspace.id,
path: workspace.path,
title: workspace.title,
sessionIds: [...workspace.sessionIds],
createdAt: workspace.createdAt,
updatedAt: workspace.updatedAt,
}
}
/** Wire projection of the durable record carried by `domain/changed`. */
function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceView {
const record: WorkspaceRecord = workspaceRecord.parse(value)
return {
workspaceId: workspaceId as WorkspaceId,
path: record.path,
title: record.title,
sessionIds: [...record.sessionIds],
createdAt: record.createdAt,
updatedAt: record.updatedAt,
}
}
/**
* Implement ApiProxy over a composed host context.
* @param ctx - a context with the Host spine and Workspace registry mounted.
* @param defaults - host routing and project-directory defaults.
* @returns the ApiProxy implementation.
*/
export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy {
const agentOptions = { provider: defaults.provider, model: defaults.model }
/** Implicit resume of cold sessions, deduplicating concurrent calls (follows the jsonrpc sessionCreations precedent). */
const resumes = new Map<SessionId, Promise<Agent>>()
/** Client-chosen identity creation/resume, deduplicated across concurrent retries. */
const sessionCreations = new Map<SessionId, Promise<Agent>>()
/** Serializes path ownership checks with record creation across spellings. */
let workspaceCreationChain = Promise.resolve()
const pendingQuestions = new Map<RpcId, PendingQuestion>()
const muxQueues = new Set<FrameQueue<RpcRequest<MuxFrame>>>()
/** Send one transient frame to every connected mux consumer. */
function broadcast(payload: MuxFrame): void {
const envelope = frame(payload)
for (const queue of muxQueues) queue.push(envelope)
}
/** Remove a wait before settling it: synchronous deletion makes the first claimant win. */
function claimQuestion(pending: PendingQuestion, outcome: 'answered' | 'cancelled'): void {
pendingQuestions.delete(pending.rpcId)
if (pending.signal !== undefined && pending.onAbort !== undefined) {
pending.signal.removeEventListener('abort', pending.onAbort)
}
broadcast({
type: 'question/resolved', sessionId: pending.sessionId,
questionRpcId: pending.rpcId, outcome,
})
}
const disposeProvider = ctx.userInteraction.registerProvider({
ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer> {
const sessionId = request.agent?.id
if (sessionId === undefined) {
return Promise.reject(new UserInteractionError(
'web user interaction requires an agent-owned session', 'ASK_MISSING_AGENT'))
}
return new Promise<AskUserQuestionAnswer>((resolve, reject) => {
const rpcId = RpcId(randomUUID())
const pending: PendingQuestion = {
rpcId, sessionId, questions: request.questions, resolve, reject,
...(request.signal === undefined ? {} : { signal: request.signal }),
}
const onAbort = (): void => {
claimQuestion(pending, 'cancelled')
reject(new UserInteractionError(
'ask_user_question was aborted before the user answered', 'ASK_ABORTED'))
}
pending.onAbort = onAbort
pendingQuestions.set(rpcId, pending)
request.signal?.addEventListener('abort', onAbort, { once: true })
const envelope: RpcRequest<MuxFrame> = {
rpcId,
payload: { type: 'question/requested', sessionId, questions: request.questions },
}
for (const queue of muxQueues) queue.push(envelope)
})
},
})
ctx.effect(() => () => {
disposeProvider()
for (const pending of [...pendingQuestions.values()]) {
claimQuestion(pending, 'cancelled')
pending.reject(new UserInteractionError(
'web user-interaction provider was disposed', 'ASK_ABORTED'))
}
}, 'api-proxy: user-interaction provider')
/**
* Gate the cold path on the store: an id absent from it, or naming a legacy
* log without a cwd (pre-release stance: not served, no compatibility), is
* not-found before any resume is attempted. With the gate passed, a later
* resume failure is genuinely internal. No persistence configured skips the
* gate — resume itself then fails loud with its own diagnostic.
*/
async function assertServable(sessionId: SessionId): Promise<void> {
const persistence = ctx.get('sessionPersistence')
if (persistence === undefined) return
const meta = (await persistence.list()).find(m => m.id === sessionId)
if (meta === undefined || meta.cwd === undefined) throw new SessionNotFound(`session "${sessionId}" not found`)
}
async function agentFor(sessionId: SessionId): Promise<{ agent: Agent } | { error: RpcError }> {
const live = ctx.agents.get(sessionId)
if (live !== undefined) return { agent: live }
let resume = resumes.get(sessionId)
if (resume === undefined) {
resume = (async () => {
try {
await assertServable(sessionId)
const handle = await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions })
return handle.agent
} finally {
resumes.delete(sessionId)
}
})()
resumes.set(sessionId, resume)
}
try {
return { agent: await resume }
} catch (error: unknown) {
if (error instanceof SessionNotFound) {
return { error: { code: 'session-not-found', message: error.message, details: { sessionId } } }
}
// The internal details slot is contractually {}; the reason rides the message.
return { error: { code: 'internal', message: `resume failed for session "${sessionId}": ${String(error)}`, details: {} } }
}
}
/** Resolve one requested identity to a live agent, creating or resuming it once. */
async function ensureSession(sessionId: SessionId, cwd: string, checkPersistedIdentity: boolean): Promise<Agent> {
let creation = sessionCreations.get(sessionId)
if (creation === undefined) {
creation = (async () => {
const live = ctx.agents.get(sessionId)
if (live !== undefined) return live
const persistence = checkPersistedIdentity ? ctx.get('sessionPersistence') : undefined
const stored = persistence === undefined
? undefined
: (await persistence.list()).find(header => header.id === sessionId)
if (stored !== undefined) {
if (stored.cwd !== cwd) {
throw new SessionCwdConflict(sessionId, cwd, stored.cwd)
}
return (await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions })).agent
}
try {
await mkdir(cwd, { recursive: true })
} catch (error: unknown) {
throw new Error(`failed to ensure project directory "${cwd}": ${String(error)}`, { cause: error })
}
return (await ctx.agents.create({ sessionId, agentOptions, meta: { cwd } })).agent
})().catch((error: unknown) => {
// Another Host entry path may have published the same identity while
// this operation crossed an asynchronous persistence/filesystem step.
const live = ctx.agents.get(sessionId)
if (live !== undefined) return live
throw error
}).finally(() => {
sessionCreations.delete(sessionId)
})
sessionCreations.set(sessionId, creation)
}
const agent = await creation
if (agent.session.header.cwd !== cwd) {
throw new SessionCwdConflict(sessionId, cwd, agent.session.header.cwd)
}
return agent
}
/** Resolve or create one path while holding the Host's workspace-create chain. */
function ensureWorkspace(
path: string,
title: string | undefined,
rejectExistingName = false,
createDirectory = false,
): Promise<{ workspace: Workspace; created: boolean }> {
const operation = workspaceCreationChain.then(async () => {
if (rejectExistingName && title !== undefined
&& ctx.workspace.list().some(workspace => workspace.title === title)) {
throw new WorkspaceNameConflictError(title)
}
if (createDirectory) {
try {
await mkdir(path, { recursive: true })
} catch (error: unknown) {
throw new WorkspaceDirectoryCreationError(
`failed to create workspace directory "${path}": ${String(error)}`,
)
}
}
const existing = await ctx.workspace.resolveByPath(path)
if (existing !== undefined) return { workspace: existing, created: false }
return { workspace: await ctx.workspace.create(path, title), created: true }
})
workspaceCreationChain = operation.then(() => undefined, () => undefined)
return operation
}
return {
sessions: {
// Attached sessions summarize from memory; persisted-but-unattached (cold)
// sessions merge in from the persistence store so history survives restarts.
// Legacy logs without a cwd (pre-project stance) are not served — every
// session now records its project at create time.
async list(request) {
const items = ctx.sessions.list().map((session) => {
const agent = ctx.agents.get(session.id)
return summarize(session, agent?.status === 'running')
})
const attached = new Set(items.map(item => item.sessionId))
const persistence = ctx.get('sessionPersistence')
if (persistence !== undefined) {
const cold = (await persistence.list()).filter(meta => !attached.has(meta.id) && meta.cwd !== undefined)
items.push(...await Promise.all(cold.map(meta => summarizeCold(persistence, meta))))
}
items.sort((a, b) => b.updatedAt - a.updatedAt)
return ok(request, { items })
},
async create(request) {
const sessionId = request.payload.sessionId ?? `session-${randomUUID()}` as SessionId
let workspace: Workspace | undefined
if (request.payload.workspaceId !== undefined) {
workspace = ctx.workspace.get(brandWorkspaceId(request.payload.workspaceId))
if (workspace === undefined) {
return err(request, {
code: 'workspace-not-found',
message: `workspace "${request.payload.workspaceId}" not found`,
details: { workspaceId: request.payload.workspaceId },
})
}
}
const cwd = workspace?.path ?? request.payload.cwd ?? defaults.cwd
try {
await ensureSession(sessionId, cwd, request.payload.sessionId !== undefined)
} catch (error: unknown) {
if (error instanceof SessionCwdConflict) {
return err(request, {
code: 'session-conflict',
message: error.message,
details: {
sessionId: error.sessionId,
requestedCwd: error.requestedCwd,
...error.existingCwd === undefined ? {} : { existingCwd: error.existingCwd },
},
})
}
return err(request, {
code: 'internal',
message: `failed to create session "${sessionId}": ${String(error)}`,
details: {},
})
}
if (workspace !== undefined) {
try {
await workspace.attachSession(sessionId)
} catch (error: unknown) {
return err(request, {
code: 'workspace-attach-failed',
message: `session "${sessionId}" was created but could not attach to workspace "${workspace.id}": ${String(error)}`,
details: { sessionId, workspaceId: workspace.id },
})
}
}
return ok(request, { sessionId })
},
async history(request) {
const { sessionId, beforeSeq, maxMessages } = request.payload
const found = await agentFor(sessionId)
if ('error' in found) return err(request, found.error)
const page = paginate(found.agent.session.events, beforeSeq, maxMessages ?? DEFAULT_MAX_MESSAGES)
// Views are computed against the registry at pagination time; result
// pairing scans within the page only (message-boundary pagination keeps
// a call and its result on one page — a cross-page miss soft-falls).
const entries: HistoryEntry[] = page.events.map((event) => {
const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId))
return { event, ...view === undefined ? {} : { view } }
})
return ok(request, { events: entries, hasMore: page.hasMore })
},
async prompt(request) {
const { sessionId, mode, content } = request.payload
const found = await agentFor(sessionId)
if ('error' in found) return err(request, found.error)
const agent = found.agent
// The rpcId rides MessageSource into user/message (merge declaration in api/sessions.ts; provisional correlation).
const source: MessageSource = { kind: 'user', rpcId: request.rpcId }
try {
if (content.some(part => part.type === 'image')) {
const routed = agent.session.requestHeader()?.config
const provider = routed?.provider ?? agent.options.provider ?? defaults.provider
const model = routed?.model ?? agent.options.model ?? defaults.model
const activeModel = (await ctx.llm.listModels(provider)).find(candidate => candidate.id === model)
if (activeModel?.inputModalities !== undefined && !activeModel.inputModalities.includes('image')) {
return err(request, {
code: 'attachment-error',
message: `Model "${model}" does not support image input.`,
details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' },
})
}
}
const durable = await durablePromptContent(ctx, content)
if (mode === 'steer') agent.steer(durable, { source })
else agent.followup(durable, { source })
} catch (error: unknown) {
if (error instanceof AttachmentError) {
return err(request, {
code: 'attachment-error',
message: error.message,
details: { reason: error.code },
})
}
// A synchronous throw from send/steer means disposed or invalid input; surface as agent-busy with the reason attached.
return err(request, { code: 'agent-busy', message: 'prompt rejected', details: { reason: String(error) } })
}
return ok(request, { accepted: true as const })
},
async attachment(request) {
const { sessionId, attachmentId } = request.payload
const found = await agentFor(sessionId)
if ('error' in found) return err(request, found.error)
const ref = referencedImage(found.agent.session.events, String(attachmentId))
if (ref === undefined) {
return err(request, {
code: 'attachment-error',
message: 'Image is not referenced by this session.',
details: { reason: 'ATTACHMENT_NOT_REFERENCED' },
})
}
try {
const stored = await ctx.attachments.readImage(ref)
return ok(request, {
attachment: stored.ref,
data: Buffer.from(stored.data).toString('base64'),
})
} catch (error: unknown) {
if (error instanceof AttachmentError) {
return err(request, {
code: 'attachment-error',
message: error.message,
details: { reason: error.code },
})
}
return err(request, {
code: 'internal',
message: 'Unable to read image attachment.',
details: {},
})
}
},
cancel(request) {
const { sessionId } = request.payload
const agent = ctx.agents.get(sessionId)
if (agent === undefined) {
return Promise.resolve(err(request, {
code: 'session-not-found',
message: `session "${sessionId}" not found (not attached)`,
details: { sessionId },
}))
}
agent.cancel()
return Promise.resolve(ok(request, { accepted: true as const }))
},
},
workspace: {
list(request) {
return Promise.resolve(ok(request, { items: ctx.workspace.list().map(workspaceView) }))
},
// Exactly one of path/name arrives (schema refine). Existing-folder
// adoption reuses its canonical path; create-by-name rejects a name
// already present in the registry.
async create(request) {
const { payload } = request
let path: string
if (payload.name !== undefined) {
const name = payload.name.trim()
if (name === '' || name === '.' || name === '..' || /[/\\]/.test(name)) {
return err(request, {
code: 'workspace-invalid-path',
message: `workspace name must be one non-empty path segment, got "${payload.name}"`,
details: { path: payload.name },
})
}
path = join(defaults.workspaceRoot, name)
} else {
path = payload.path as string
}
try {
const name = payload.name?.trim()
const { workspace, created } = await ensureWorkspace(
path,
name,
name !== undefined,
name !== undefined,
)
return ok(request, { workspace: workspaceView(workspace), created })
} catch (error: unknown) {
if (error instanceof WorkspaceNameConflictError) {
return err(request, {
code: 'workspace-name-conflict',
message: error.message,
details: { name: error.workspaceName },
})
}
if (error instanceof WorkspaceDirectoryCreationError) {
return err(request, { code: 'internal', message: error.message, details: {} })
}
// The registry rejects a path that does not resolve to an existing
// directory (realpath ENOENT / not-a-directory) — the business
// error of the typed-path flow, surfaced as a validation failure.
return err(request, {
code: 'workspace-invalid-path',
message: `cannot create a workspace at "${path}": ${error instanceof Error ? error.message : String(error)}`,
details: { path },
})
}
},
},
host: {
async describe(request) {
const activeModel = (await ctx.llm.listModels(defaults.provider))
.find(model => model.id === defaults.model)
// TODO(step2): version should read apps/cli's package.json; placeholder for now.
return ok(request, {
version: '0.0.1',
// Same source as session.create's fallback: the UI's default project
// must match where an unspecified-cwd session actually lands.
cwd: defaults.cwd,
provider: defaults.provider,
model: defaults.model,
...activeModel === undefined ? {} : { activeModel },
imageLimits: {
...ctx.attachments.imageLimits,
mediaTypes: [...ctx.attachments.imageLimits.mediaTypes],
},
attachedSessions: ctx.agents.list().length,
})
},
},
events: {
mux(_request, signal) {
const queue = new FrameQueue<RpcRequest<MuxFrame>>()
muxQueues.add(queue)
for (const session of ctx.sessions.list()) {
subscribeSession(queue, session)
}
for (const pending of pendingQuestions.values()) {
queue.push({
rpcId: pending.rpcId,
payload: {
type: 'question/requested', sessionId: pending.sessionId,
questions: pending.questions,
},
})
}
// Per-session open-call table for result-view pairing. Bounded by the
// per-turn call count: entries clear on turn/end; a table miss (stream
// opened mid-turn) backscans the session's in-memory events instead.
const openCalls = new Map<SessionId, Map<string, { name: string; args: unknown }>>()
const disposers = [
ctx.on('session/event', (session: Session, event: SessionEvent) => {
if (event.type === 'tool/call') {
const data = event.data as ToolCallData
try {
let table = openCalls.get(session.id)
if (table === undefined) openCalls.set(session.id, table = new Map<string, { name: string; args: unknown }>())
table.set(data.callId, { name: data.name, args: JSON.parse(data.arguments) })
} catch {
// Unparseable model arguments: leave the table unset; the result view soft-falls.
}
} else if (event.type === 'turn/end') {
openCalls.delete(session.id)
}
const view = viewFor(ctx, event, callId =>
openCalls.get(session.id)?.get(callId) ?? backscanArgs(session.events, callId))
queue.push(frame({ type: 'session/event', sessionId: session.id, event, ...view === undefined ? {} : { view } }))
if (event.type === 'session/title') {
// The accepted raw event is already in session.events, so the fold must find it.
queue.push(frame(titleFrame(session) as SessionTitleFrame))
}
}),
ctx.on('session/created', (session: Session) => {
subscribeSession(queue, session)
}),
ctx.on('session/disposed', (session: Session) => {
openCalls.delete(session.id)
}),
]
return queue.iterate(signal, () => {
muxQueues.delete(queue)
for (const dispose of disposers) dispose()
})
},
host(_request, signal) {
const queue = new FrameQueue<RpcRequest<HostFrame>>()
const committedWorkspaceIds = new Set(
ctx.workspace.list().map(workspace => String(workspace.id)),
)
const disposers = [
ctx.on('session/created', (session: Session) => {
queue.push(frame({
type: 'host/session-added',
sessionId: session.id,
...session.header.parentSession === undefined ? {} : { parentSessionId: session.header.parentSession },
// cwd rides the frame so the client list needs no refresh to group the new session.
...session.header.cwd === undefined ? {} : { cwd: session.header.cwd },
}))
}),
ctx.on('session/disposed', (session: Session) => {
queue.push(frame({ type: 'host/session-removed', sessionId: session.id }))
}),
ctx.on('agent/status', (agent: Agent, status: AgentStatus) => {
if (status === 'disposed') return
queue.push(frame({ type: 'host/session-status', sessionId: agent.id, running: status === 'running' }))
}),
ctx.on('agent/error', (agent: Agent, _turn: number, _step: number, error: Error) => {
queue.push(frame({ type: 'host/agent-error', sessionId: agent.id, message: String(error) }))
}),
ctx.on('domain/changed', (change) => {
if (change.domain !== 'workspace' || change.operation !== 'put') return
if (change.table === '') {
const state = workspaceDomainState.parse(change.value)
for (const workspaceId of state.workspaceIds) {
if (committedWorkspaceIds.has(workspaceId)) continue
const workspace = ctx.workspace.get(workspaceId)
if (workspace === undefined) {
throw new Error(`committed workspace registry references missing workspace "${workspaceId}"`)
}
committedWorkspaceIds.add(workspaceId)
queue.push(frame({ type: 'host/workspace-changed', workspace: workspaceView(workspace) }))
}
return
}
if (change.table !== 'workspaces' || !committedWorkspaceIds.has(change.key)) return
// Existing-entity table writes are complete attach/touch commits.
// A new entity's first put waits for the global registry write above.
queue.push(frame({
type: 'host/workspace-changed',
workspace: changedWorkspaceView(change.key, change.value),
}))
}),
]
return queue.iterate(signal, () => { for (const dispose of disposers) dispose() })
},
},
respond(message: ClientResponse): Promise<RpcReceipt> {
const pending = pendingQuestions.get(message.rpcId)
if (pending === undefined) return Promise.resolve({ accepted: false, reason: 'not-pending' })
if (!message.result.ok) {
if (message.result.error.code !== 'cancelled') {
return Promise.resolve({ accepted: false, reason: 'bad-response' })
}
claimQuestion(pending, 'cancelled')
pending.reject(new UserInteractionError(
'the user cancelled ask_user_question', 'ASK_CANCELLED'))
return Promise.resolve({ accepted: true })
}
const parsed = questionResponsePayloadSchema.safeParse(message.result.value)
if (!parsed.success) {
return Promise.resolve({ accepted: false, reason: 'bad-response' })
}
const payload: QuestionResponsePayload = {
sessionId: parsed.data.sessionId,
answer: {
answers: parsed.data.answer.answers.map(answer => ({
id: answer.id,
selected: answer.selected,
...(answer.custom === undefined ? {} : { custom: answer.custom }),
})),
},
}
if (!matchesQuestions(payload, pending)) {
return Promise.resolve({ accepted: false, reason: 'bad-response' })
}
claimQuestion(pending, 'answered')
pending.resolve(payload.answer)
return Promise.resolve({ accepted: true })
},
}
}

View File

@@ -11,6 +11,7 @@ import type { Wire } from './rpc.schema.ts'
import { rpcErrorSchema, rpcIdSchema } from './rpc.schema.ts'
import { approvalRequestIdSchema } from './approvals.schema.ts'
import { sessionEventSchema, sessionIdSchema, toolEventViewSchema } from './sessions.schema.ts'
import { workspaceViewSchema } from './workspace.schema.ts'
/** Question shape validated strictly against core dsh-user-interaction. */
export const askUserQuestionItemSchema = z.object({
@@ -39,9 +40,10 @@ export const muxFrameSchema = z.discriminatedUnion('type', [
/** HostFrame union (payload slot of a host-stream ServerRequest). */
export const hostFrameSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('host/session-added'), sessionId: sessionIdSchema, parentSessionId: sessionIdSchema.optional() }),
z.object({ type: z.literal('host/session-added'), sessionId: sessionIdSchema, parentSessionId: sessionIdSchema.optional(), cwd: z.string().optional() }),
z.object({ type: z.literal('host/session-removed'), sessionId: sessionIdSchema }),
z.object({ type: z.literal('host/session-status'), sessionId: sessionIdSchema, running: z.boolean() }),
z.object({ type: z.literal('host/agent-error'), sessionId: sessionIdSchema, message: z.string() }),
z.object({ type: z.literal('host/workspace-changed'), workspace: workspaceViewSchema }),
z.object({ type: z.literal('stream/error'), error: rpcErrorSchema }),
]) as unknown as z.ZodType<HostFrame>

View File

@@ -12,6 +12,7 @@ import type { CallId } from '@deepseek-ai/dsh-llm/brand'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
import type { RpcError, RpcId, RpcRequest } from './rpc.ts'
import type { WorkspaceView } from './workspace.ts'
// Client-side consumers take the render-intent vocabulary from the contract;
// dsh-tools remains its owner.
@@ -62,10 +63,18 @@ export type MuxFrame =
| { type: 'question/resolved'; sessionId: SessionId; questionRpcId: RpcId; outcome: 'answered' | 'cancelled' }
| { type: 'stream/error'; error: RpcError }
/** Host stream frames. session-added carries the lineage anchor; agent-error is the only outlet for live failures with no turn position. */
/**
* Host stream frames. session-added carries the lineage anchor and the
* project cwd (the list-summary fields a client cannot wait for a refresh to
* learn); agent-error is the only outlet for live failures with no turn
* position; workspace-changed pushes the full new snapshot after every
* durable workspace mutation (create/attach/order change — the client
* upserts, while `workspace.list` provides the reconnect baseline).
*/
export type HostFrame =
| { type: 'host/session-added'; sessionId: SessionId; parentSessionId?: SessionId }
| { type: 'host/session-added'; sessionId: SessionId; parentSessionId?: SessionId; cwd?: string }
| { type: 'host/session-removed'; sessionId: SessionId }
| { type: 'host/session-status'; sessionId: SessionId; running: boolean }
| { type: 'host/agent-error'; sessionId: SessionId; message: string }
| { type: 'host/workspace-changed'; workspace: WorkspaceView }
| { type: 'stream/error'; error: RpcError }

View File

@@ -6,6 +6,7 @@
import type { SessionsApi } from './sessions.ts'
import type { HostApi } from './host.ts'
import type { WorkspaceApi } from './workspace.ts'
import type { EventsApi } from './events.ts'
import type { ClientResponse, RpcReceipt } from './rpc.ts'
@@ -13,6 +14,7 @@ import type { ClientResponse, RpcReceipt } from './rpc.ts'
export interface ApiProxy {
sessions: SessionsApi
host: HostApi
workspace: WorkspaceApi
events: EventsApi
/** Response entry for server-requests (client-response, echoing their rpcId); not a domain method (four-quadrant model). */
respond(message: ClientResponse): Promise<RpcReceipt>
@@ -21,6 +23,7 @@ export interface ApiProxy {
// ---- Domain interfaces and payload entities ----
export type { HistoryEntry, PromptContentPart, SessionsApi, SessionSummary } from './sessions.ts'
export type { HostApi } from './host.ts'
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
export type { EventsApi, MuxFrame, HostFrame, ToolCallView, ToolEventView, ToolResultView } from './events.ts'
export type { ApprovalResponsePayload } from './approvals.ts'
export type { QuestionResponsePayload } from './questions.ts'

View File

@@ -6,6 +6,7 @@
import type { SessionsApi } from './sessions.ts'
import type { HostApi } from './host.ts'
import type { WorkspaceApi } from './workspace.ts'
import type { RpcResponse } from './rpc.ts'
/** Method name → method signature. Signatures are the single source of truth; payload/value types are always derived from here. */
@@ -17,6 +18,8 @@ export interface RpcMethodMap {
'session.attachment': SessionsApi['attachment']
'session.cancel': SessionsApi['cancel']
'host.describe': HostApi['describe']
'workspace.list': WorkspaceApi['list']
'workspace.create': WorkspaceApi['create']
}
/** Business request payload of method K (reaches through the RpcRequest narrow form to payload). */

View File

@@ -35,6 +35,11 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
z.object({ code: z.literal('bad-request'), message: z.string(), details: z.object({ issues: z.array(z.custom<ZodIssue>()) }) }),
z.object({ code: z.literal('cancelled'), message: z.string(), details: z.object({}) }),
z.object({ code: z.literal('session-not-found'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
z.object({ code: z.literal('session-conflict'), message: z.string(), details: z.object({ sessionId: z.string(), requestedCwd: z.string(), existingCwd: z.string().optional() }) }),
z.object({ code: z.literal('workspace-attach-failed'), message: z.string(), details: z.object({ sessionId: z.string(), workspaceId: z.string() }) }),
z.object({ code: z.literal('workspace-not-found'), message: z.string(), details: z.object({ workspaceId: z.string() }) }),
z.object({ code: z.literal('workspace-invalid-path'), message: z.string(), details: z.object({ path: z.string() }) }),
z.object({ code: z.literal('workspace-name-conflict'), message: z.string(), details: z.object({ name: z.string() }) }),
z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }),
z.object({ code: z.literal('attachment-error'), message: z.string(), details: z.object({ reason: z.string() }) }),
z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }),

View File

@@ -32,6 +32,11 @@ export interface RpcErrorDetailsMap {
'bad-request': { issues: ZodIssue[] }
'cancelled': {}
'session-not-found': { sessionId: SessionId }
'session-conflict': { sessionId: SessionId; requestedCwd: string; existingCwd?: string }
'workspace-attach-failed': { sessionId: SessionId; workspaceId: string }
'workspace-not-found': { workspaceId: string }
'workspace-invalid-path': { path: string }
'workspace-name-conflict': { name: string }
'agent-busy': { reason: string }
'attachment-error': { reason: string }
'internal': {}

View File

@@ -12,10 +12,19 @@ import type { Wire } from './rpc.schema.ts'
import type { HistoryEntry, SessionSummary } from './sessions.ts'
import type { ToolEventView } from './events.ts'
import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { WorkspaceId } from './workspace.ts'
/** SessionId: one brand cast after shape validation (the only cast point in this domain). */
export const sessionIdSchema = z.string().min(1) as unknown as z.ZodType<SessionId>
/**
* WorkspaceId: the workspace domain's one brand cast. Hosted here rather
* than in workspace.schema because session.create references it while
* workspace.schema references sessionIdSchema — schema modules must stay a
* DAG (both casts used at module top level; a cycle is a load-time TDZ).
*/
export const workspaceIdSchema = z.string().min(1) as unknown as z.ZodType<WorkspaceId>
/** SessionEvent passthrough: strict envelope, wide data (the client fold handles unknown types via its documented default). */
export const sessionEventSchema = z.object({
type: z.string(),
@@ -45,10 +54,15 @@ export const sessionListValueSchema = z.object({
items: z.array(sessionSummarySchema),
}) satisfies z.ZodType<Wire<ResponseValue<'session.list'>>>
/** session.create request payload. */
/** session.create request payload (at most one of workspaceId / cwd). */
export const sessionCreateRequestSchema = z.object({
workspaceId: workspaceIdSchema.optional(),
cwd: z.string().optional(),
}) satisfies z.ZodType<Wire<RequestPayload<'session.create'>>>
sessionId: sessionIdSchema.optional(),
}).refine(
payload => payload.workspaceId === undefined || payload.cwd === undefined,
{ message: 'session.create accepts workspaceId or cwd, not both' },
) satisfies z.ZodType<Wire<RequestPayload<'session.create'>>>
/** session.create response value. */
export const sessionCreateValueSchema = z.object({

View File

@@ -8,6 +8,7 @@ import type { AttachmentIdType, ImageAttachmentRef, ImageMediaType } from '@deep
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
import type { RpcId, RpcRequest, RpcResponse } from './rpc.ts'
import type { ToolEventView } from './events.ts'
import type { WorkspaceId } from './workspace.ts'
declare module '@deepseek-ai/dsh-llm' {
interface MessageSourceMap {
@@ -54,8 +55,16 @@ export interface SessionsApi {
/** Lists persisted sessions (updatedAt descending). v1 returns everything; cursor is a reserved seat, unimplemented. */
list(request: RpcRequest<{ cursor?: string }>): Promise<RpcResponse<{ items: SessionSummary[] }>>
/** Creates a new session (and its agent, idle and standing by). */
create(request: RpcRequest<{ cwd?: string }>): Promise<RpcResponse<{ sessionId: SessionId }>>
/**
* Creates a real session and its idle agent. At most one of `workspaceId` /
* `cwd` is accepted; an omitted project uses the Host cwd. A caller may
* preallocate `sessionId`: retries with the same id and cwd return the same
* session, while a different cwd fails with `session-conflict`.
* Workspace creation attaches the session after publication; an attach
* failure returns `workspace-attach-failed` with the published session id.
*/
create(request: RpcRequest<{ workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId }>):
Promise<RpcResponse<{ sessionId: SessionId }>>
/**
* Reads a window of history events; page boundaries align to message boundaries: one page =

View File

@@ -0,0 +1,46 @@
/**
* workspace domain zod schemas (names derived from map keys). The
* WorkspaceId brand cast lives in sessions.schema (see the note there) and
* is re-exported here as the domain-local name.
*/
import { z } from 'zod'
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
import type { Wire } from './rpc.schema.ts'
import type { WorkspaceView } from './workspace.ts'
import { sessionIdSchema, workspaceIdSchema } from './sessions.schema.ts'
export { workspaceIdSchema } from './sessions.schema.ts'
/** WorkspaceView row of every workspace.* response. */
export const workspaceViewSchema = z.object({
workspaceId: workspaceIdSchema,
path: z.string(),
title: z.string(),
sessionIds: z.array(sessionIdSchema),
createdAt: z.string(),
updatedAt: z.string(),
}) satisfies z.ZodType<Wire<WorkspaceView>>
/** workspace.list request payload (empty object literal). */
export const workspaceListRequestSchema = z.object({}) satisfies z.ZodType<Wire<RequestPayload<'workspace.list'>>>
/** workspace.list response value. */
export const workspaceListValueSchema = z.object({
items: z.array(workspaceViewSchema),
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.list'>>>
/** workspace.create request payload: exactly one of path/name (the contract's create spellings). */
export const workspaceCreateRequestSchema = z.object({
path: z.string().optional(),
name: z.string().optional(),
}).refine(
payload => (payload.path === undefined) !== (payload.name === undefined),
{ message: 'workspace.create requires exactly one of path / name' },
) satisfies z.ZodType<Wire<RequestPayload<'workspace.create'>>>
/** workspace.create response value. */
export const workspaceCreateValueSchema = z.object({
workspace: workspaceViewSchema,
created: z.boolean(),
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.create'>>>

View File

@@ -0,0 +1,55 @@
/**
* workspace domain contract. Wire projection of the host-side workspace
* entity (@deepseek-ai/dsh-workspace): a stable id over a directory path,
* a display title, and the ordered session account. Method signatures are the
* source of truth, same as the sessions domain.
*/
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { RpcRequest, RpcResponse } from './rpc.ts'
/**
* Wire-side workspace id brand. Deliberately re-declared here rather than
* imported from dsh-workspace: api/ must stay browser-importable with zero
* host-package dependencies, and the brand string matches, so both sides
* agree structurally.
*/
export type WorkspaceId = Branded<'WorkspaceId'>
/** One workspace row: the record projection every workspace.* value carries. */
export interface WorkspaceView {
workspaceId: WorkspaceId
/** Canonical directory path (host-side realpath canon). */
path: string
/** Unique display title (defaults to the path basename at create). */
title: string
/** Sessions accounted under this workspace, newest-first for display. */
sessionIds: SessionId[]
/** ISO-8601 creation instant. */
createdAt: string
/** ISO-8601 last-mutation instant. */
updatedAt: string
}
/** Workspace-domain unary methods (the map keys workspace.* of RpcMethodMap). */
export interface WorkspaceApi {
/** Lists all workspaces in the registry's durable display order. */
list(request: RpcRequest<{}>): Promise<RpcResponse<{ items: WorkspaceView[] }>>
/**
* Creates (or idempotently resolves) a workspace. Exactly one of `path` /
* `name` (schema-enforced): `path` registers an EXISTING directory (no
* mkdir — a missing or non-directory path fails with `workspace-invalid-path`);
* `name` is a single path segment the host mkdirs under its default project
* root before registering. Either spelling resolving to a directory already
* owned by a workspace returns that workspace (`created: false`) for the
* existing-folder spelling. Create-by-name rejects an existing title with
* `workspace-name-conflict`; a new path whose basename duplicates another
* Workspace title is rejected by the registry with the same code.
* A new name-created workspace uses `name` as both directory name and title;
* a path-created workspace uses the registry's basename title default.
*/
create(request: RpcRequest<{ path?: string; name?: string }>):
Promise<RpcResponse<{ workspace: WorkspaceView; created: boolean }>>
}

View File

@@ -22,6 +22,10 @@ import {
sessionListValueSchema,
sessionPromptValueSchema,
} from '../api/sessions.schema.ts'
import {
workspaceCreateValueSchema,
workspaceListValueSchema,
} from '../api/workspace.schema.ts'
/**
* Client consumption face of the contract (shape a): same domain tree as ApiProxy, but unary
@@ -50,6 +54,10 @@ export interface IApiClient {
host: {
describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.describe'>>>
}
workspace: {
list(payload: RequestPayload<'workspace.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.list'>>>
create(payload: RequestPayload<'workspace.create'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.create'>>>
}
events: {
mux(payload: Parameters<ApiProxy['events']['mux']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<MuxFrame>>
host(payload: Parameters<ApiProxy['events']['host']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<HostFrame>>
@@ -70,6 +78,8 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
'session.attachment': sessionAttachmentValueSchema,
'session.cancel': sessionCancelValueSchema,
'host.describe': hostDescribeValueSchema,
'workspace.list': workspaceListValueSchema,
'workspace.create': workspaceCreateValueSchema,
}
/** Default unary timeout (rpc-compare 2026-07-19: a hung host must not leave callers pending forever). */
@@ -257,6 +267,11 @@ export abstract class AbstractApiClient implements IApiClient {
describe: (payload, signal) => this.callUnary('host.describe', payload, signal),
}
readonly workspace: IApiClient['workspace'] = {
list: (payload, signal) => this.callUnary('workspace.list', payload, signal),
create: (payload, signal) => this.callUnary('workspace.create', payload, signal),
}
readonly events: IApiClient['events'] = {
mux: (payload, signal, onOpen) => this.openMux(payload, signal, onOpen),
host: (payload, signal, onOpen) => this.openHost(payload, signal, onOpen),

View File

@@ -23,6 +23,10 @@ import {
sessionPromptRequestSchema,
} from '../api/sessions.schema.ts'
import { hostDescribeRequestSchema } from '../api/host.schema.ts'
import {
workspaceCreateRequestSchema,
workspaceListRequestSchema,
} from '../api/workspace.schema.ts'
/**
* Unary dispatch table, keyed by (and compiler-locked to) RpcMethodMap: a map row without a
@@ -46,6 +50,8 @@ const UNARY_ROUTES: UnaryRoutes = {
'session.attachment': { schema: sessionAttachmentRequestSchema, invoke: (api, r) => api.sessions.attachment(r) },
'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) },
'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) },
'workspace.list': { schema: workspaceListRequestSchema, invoke: (api, r) => api.workspace.list(r) },
'workspace.create': { schema: workspaceCreateRequestSchema, invoke: (api, r) => api.workspace.create(r) },
}
/** Route lookup that narrows an arbitrary path segment to a map key (single cast point for the string→key refinement). */

View File

@@ -1,13 +1,81 @@
/**
* @deepseek-ai/dsh-host-apiproxy — the front layer every client shape shares:
* the ApiProxy contract (api/: types + zod schemas, browser-safe) and the
* fetch carrier pair (fetch/: toFetchHandler on the host side, AbstractApiClient +
* platform subclasses on the client side). Host assembly (bootHost/createApiProxy/startHost)
* lives in @deepseek-ai/dsh-host-runtime.
* @deepseek-ai/dsh-host-apiproxy — the API gateway every client shape shares:
* the ApiProxy contract (api/: types + zod schemas, browser-safe), the fetch
* carrier pair (fetch/: toFetchHandler on the host side, AbstractApiClient +
* platform subclasses on the client side), and the host-side implementation
* (api-proxy.ts: createApiProxy + the ApiProxyService gateway plugin providing
* `ctx.apiProxy`). Transport-agnostic by design: this package registers no
* routes — carriers (HTTP today, IPC later) wrap `ctx.apiProxy` themselves.
*/
import { resolve } from 'node:path'
import { Context, Service } from 'cordis'
import z from 'schemastery'
import type { ApiProxy } from './api/index.ts'
import { createApiProxy } from './api-proxy.ts'
export type * from './api/index.ts'
export { RpcId } from './api/rpc.ts'
export { toFetchHandler } from './fetch/handler.ts'
export { AbstractApiClient, InProcessApiClient } from './fetch/client.ts'
export type { IApiClient } from './fetch/client.ts'
export { createApiProxy } from './api-proxy.ts'
export type { ApiProxyDefaults } from './api-proxy.ts'
declare module 'cordis' {
interface Context {
/** The host-side ApiProxy implementation (the transport-agnostic gateway face). */
apiProxy: ApiProxy
}
}
/** Gateway plugin config: host-level agent routing and Workspace creation root. */
export interface Config {
/** Default provider route for created/resumed agents. */
provider: string
/** Default model id. */
model: string
/** Parent directory for name-created Workspaces; defaults to the Host cwd. */
workspaceRoot?: string
}
/**
* The API gateway service: implements the ApiProxy contract over the composed
* host context and provides it as `ctx.apiProxy`. The Host cwd is the default
* project directory and the fallback parent for name-created Workspaces.
*/
export class ApiProxyService extends Service implements ApiProxy {
static inject = ['agents', 'attachments', 'llm', 'sessions', 'tools', 'userInteraction', 'workspace']
static Config: z<Config> = z.object({
provider: z.string().required(),
model: z.string().required(),
workspaceRoot: z.string(),
})
readonly sessions: ApiProxy['sessions']
readonly workspace: ApiProxy['workspace']
readonly host: ApiProxy['host']
readonly events: ApiProxy['events']
readonly respond: ApiProxy['respond']
constructor(ctx: Context, config: Config) {
super(ctx, 'apiProxy')
const cwd = process.cwd()
const api = createApiProxy(ctx, {
provider: config.provider,
model: config.model,
cwd,
workspaceRoot: resolve(config.workspaceRoot ?? cwd),
})
this.sessions = api.sessions
this.workspace = api.workspace
this.host = api.host
this.events = api.events
// createApiProxy returns closures (no `this` capture); bind only satisfies
// the unbound-method lint without changing behavior.
this.respond = api.respond.bind(api)
}
}
export default ApiProxyService

View File

@@ -15,11 +15,12 @@ export const name = 'host-apiproxy-invariant'
export const inject = ['invariants']
/**
* No runtime invariant: this package is the wire contract layer (types,
* schemas, fetch carrier glue) — it emits no cordis events and owns no
* mutable cross-plugin relation. rpcId round-trip and schema acceptance are
* enforced at the carrier boundary and exercised by the protocol-isomorphism
* suite; the live implementation relations belong to dsh-host-runtime.
* No runtime invariant: this package is the wire contract layer plus the
* host-side gateway over services owned elsewhere — it emits no cordis events
* of its own; the session/agent event streams it projects are asserted by
* their owning packages' companions. rpcId round-trip and schema acceptance
* are enforced at the carrier boundary and exercised by the
* protocol-isomorphism suite.
*/
const install: InvariantInstaller = () => {}