Merge master (slash/input/session architecture) into web-session-model-selector

This commit is contained in:
imccyu
2026-07-27 10:23:51 +08:00
2673 changed files with 101832 additions and 39946 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,45 @@
/**
* commands domain zod schemas (names derived from map keys: commandListRequestSchema /
* commandListValueSchema / commandExecuteRequestSchema / commandExecuteValueSchema).
*/
import { z } from 'zod'
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
import type { Wire } from './rpc.schema.ts'
import { sessionIdSchema } from './sessions.schema.ts'
import type { CommandDescriptor, CommandExecuteResult } from './commands.ts'
/** CommandDescriptor row of command.list. */
export const commandDescriptorSchema = z.object({
name: z.string().min(1),
description: z.string(),
input: z.object({ hint: z.string() }).optional(),
}) satisfies z.ZodType<Wire<CommandDescriptor>>
/** command.list request payload. */
export const commandListRequestSchema = z.object({
sessionId: sessionIdSchema,
}) satisfies z.ZodType<Wire<RequestPayload<'command.list'>>>
/** command.list response value. */
export const commandListValueSchema = z.object({
commands: z.array(commandDescriptorSchema),
}) satisfies z.ZodType<Wire<ResponseValue<'command.list'>>>
/** command.execute request payload. */
export const commandExecuteRequestSchema = z.object({
sessionId: sessionIdSchema,
line: z.string(),
}) satisfies z.ZodType<Wire<RequestPayload<'command.execute'>>>
/** Detached command outcome (result slot of command.execute's value). */
export const commandExecuteResultSchema = z.object({
kind: z.union([z.literal('success'), z.literal('error')]),
text: z.string().optional(),
}) satisfies z.ZodType<Wire<CommandExecuteResult>>
/** command.execute response value (matched=false carries no result). */
export const commandExecuteValueSchema = z.object({
matched: z.boolean(),
result: commandExecuteResultSchema.optional(),
}) satisfies z.ZodType<Wire<ResponseValue<'command.execute'>>>

View File

@@ -0,0 +1,48 @@
/**
* commands domain contract: the web catalog/dispatch face of the host command
* registry (`ctx.commands`). Both methods address one session's agent via
* `sessionId` — every served session has an Agent (Session+Agent are born
* together), so there is no agent-less surface on this wire.
*/
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { RpcRequest, RpcResponse } from './rpc.ts'
/**
* Handler-free command view served to clients. Wire mirror of the host
* registry descriptor (which stays host-side with its cordis dependencies);
* no source field — the host descriptor has none.
*/
export interface CommandDescriptor {
/** Lowercase command name without the leading slash. */
readonly name: string
/** Human-readable summary used in discovery UI. */
readonly description: string
/** Optional free-form input hint advertised to capable clients. */
readonly input?: { readonly hint: string }
}
/** Detached command outcome rendered directly by the requesting client. */
export interface CommandExecuteResult {
readonly kind: 'success' | 'error'
readonly text?: string
}
/** Command-domain unary methods (the map keys command.* of RpcMethodMap). */
export interface CommandsApi {
/**
* Lists the addressed agent's effective command catalog (name-sorted,
* globals plus its scoped shadows).
*/
list(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<{ commands: readonly CommandDescriptor[] }>>
/**
* Parses and executes one slash-command line against the addressed agent
* without sending it to the model. matched=false when syntax or name does
* not resolve (the client falls back to its default sink). The signal rides
* beside the request, never on the wire: the fetch carrier's request signal
* cancels the running handler.
*/
execute(request: RpcRequest<{ sessionId: SessionId; line: string }>, signal: AbortSignal):
Promise<RpcResponse<{ matched: boolean; result?: CommandExecuteResult }>>
}

View File

@@ -10,7 +10,8 @@ import type { HostFrame, MuxFrame } from './events.ts'
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 { contentBlockSchema, 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({
@@ -34,14 +35,18 @@ export const muxFrameSchema = z.discriminatedUnion('type', [
// and must fail loud here, not reach the composer.
z.object({ type: z.literal('question/requested'), sessionId: sessionIdSchema, questions: z.array(askUserQuestionItemSchema).min(1) }),
z.object({ type: z.literal('question/resolved'), sessionId: sessionIdSchema, questionRpcId: rpcIdSchema, outcome: z.union([z.literal('answered'), z.literal('cancelled')]) }),
// content/source reuse the wide passthroughs (both are merge-extensible in core).
z.object({ type: z.literal('session/queued'), sessionId: sessionIdSchema, content: z.array(contentBlockSchema), source: z.looseObject({ kind: z.string() }), steering: z.boolean() }),
z.object({ type: z.literal('stream/error'), error: rpcErrorSchema }),
]) as unknown as z.ZodType<MuxFrame>
/** 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, blank: z.boolean(), 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('host/commands-changed') }),
z.object({ type: z.literal('stream/error'), error: rpcErrorSchema }),
]) as unknown as z.ZodType<HostFrame>

View File

@@ -8,10 +8,12 @@
import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-interaction/types'
import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval/types'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm/types'
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.
@@ -60,12 +62,41 @@ export type MuxFrame =
| { type: 'approval/resolved'; sessionId: SessionId; approvalId: ApprovalRequestId; outcome: ApprovalOutcome }
| { type: 'question/requested'; sessionId: SessionId; questions: AskUserQuestionItem[] }
| { type: 'question/resolved'; sessionId: SessionId; questionRpcId: RpcId; outcome: 'answered' | 'cancelled' }
/**
* A message entered the addressed agent's inbox (`agent/queued` passthrough:
* a queued message is not model-visible, so there is no session event to
* ride — this transient frame is the only wire signal). On stream open the
* host replays the current queue snapshot for every attached session (same
* refresh-recovery baseline as pending questions); queue clearing on cancel
* has no dedicated frame — clients fold it from the status flip.
* source carries the prompt's rpcId when the message came over this wire
* (the client's provisional-echo reconciliation key).
*/
| { type: 'session/queued'; sessionId: SessionId; content: ContentBlock[]; source: MessageSource; steering: boolean }
| { 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, the project
* cwd, and the blank bit (the list-summary fields a client cannot wait for a
* refresh to learn); the frame fires at session/created, so blank is
* constantly true — clients flip it on the session's first
* `host/session-status(running:true)` (a blank session never runs), and a
* reconnecting client takes `session.list`'s summary.blank as authoritative.
* 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; blank: boolean; 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 }
/**
* The command registry changed (`commands/change` passthrough). Pure
* invalidation signal, no payload: clients refetch `command.list` in the
* background rather than diffing.
*/
| { type: 'host/commands-changed' }
| { type: 'stream/error'; error: RpcError }

View File

@@ -6,6 +6,9 @@
import type { SessionsApi } from './sessions.ts'
import type { HostApi } from './host.ts'
import type { WorkspaceApi } from './workspace.ts'
import type { CommandsApi } from './commands.ts'
import type { SkillsApi } from './skills.ts'
import type { EventsApi } from './events.ts'
import type { ClientResponse, RpcReceipt } from './rpc.ts'
@@ -13,6 +16,9 @@ import type { ClientResponse, RpcReceipt } from './rpc.ts'
export interface ApiProxy {
sessions: SessionsApi
host: HostApi
workspace: WorkspaceApi
commands: CommandsApi
skills: SkillsApi
events: EventsApi
/** Response entry for server-requests (client-response, echoing their rpcId); not a domain method (four-quadrant model). */
respond(message: ClientResponse): Promise<RpcReceipt>
@@ -24,6 +30,9 @@ export type {
SessionModels, SessionsApi, SessionSummary,
} from './sessions.ts'
export type { HostApi } from './host.ts'
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
export type { CommandsApi, CommandDescriptor, CommandExecuteResult } from './commands.ts'
export type { SkillsApi, SkillEntry } from './skills.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,9 +6,16 @@
import type { SessionsApi } from './sessions.ts'
import type { HostApi } from './host.ts'
import type { WorkspaceApi } from './workspace.ts'
import type { CommandsApi } from './commands.ts'
import type { SkillsApi } from './skills.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. */
/**
* Method name → method signature. Signatures are the single source of truth; payload/value
* types are always derived from here. A method may declare a trailing AbortSignal after the
* request (command.execute): the carrier passes its request signal, never a wire field.
*/
export interface RpcMethodMap {
'session.list': SessionsApi['list']
'session.create': SessionsApi['create']
@@ -18,6 +25,13 @@ export interface RpcMethodMap {
'session.prompt': SessionsApi['prompt']
'session.cancel': SessionsApi['cancel']
'host.describe': HostApi['describe']
'workspace.list': WorkspaceApi['list']
'workspace.create': WorkspaceApi['create']
'workspace.rename': WorkspaceApi['rename']
'workspace.insertSessionBefore': WorkspaceApi['insertSessionBefore']
'command.list': CommandsApi['list']
'command.execute': CommandsApi['execute']
'skill.list': SkillsApi['list']
}
/** Business request payload of method K (reaches through the RpcRequest narrow form to payload). */

View File

@@ -35,7 +35,12 @@ 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('model-unavailable'), message: z.string(), details: z.object({ provider: z.string(), model: 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('workspace-move-invalid'), message: z.string(), details: z.object({ workspaceId: z.string(), sessionId: z.string(), beforeSessionId: z.string().optional() }) }),
z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }),
z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }),
]) as unknown as z.ZodType<RpcError>

View File

@@ -32,7 +32,12 @@ export interface RpcErrorDetailsMap {
'bad-request': { issues: ZodIssue[] }
'cancelled': {}
'session-not-found': { sessionId: SessionId }
'model-unavailable': { provider: string; model: string }
'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 }
'workspace-move-invalid': { workspaceId: string; sessionId: SessionId; beforeSessionId?: SessionId }
'agent-busy': { reason: string }
'internal': {}
}

View File

@@ -14,10 +14,19 @@ import type {
SessionSummary,
} from './sessions.ts'
import type { ToolEventView } from './events.ts'
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(),
@@ -33,6 +42,7 @@ export const sessionSummarySchema = z.object({
sessionId: sessionIdSchema,
updatedAt: z.number(),
running: z.boolean(),
blank: z.boolean(),
parentSessionId: sessionIdSchema.optional(),
cwd: z.string().optional(),
}) satisfies z.ZodType<Wire<SessionSummary>>
@@ -47,10 +57,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 { ContentBlock } from '@deepseek-ai/dsh-llm/types'
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 {
@@ -88,6 +89,14 @@ export interface SessionSummary {
updatedAt: number
/** Status of the attached agent; always false for cold (unattached) sessions. */
running: boolean
/**
* Derived emptiness bit: true while the session log holds zero events (no
* user message yet). Clients hide blank sessions from lists and reuse them
* for New Session on the same workspace. Always false for cold sessions —
* lazy persistence keeps a never-appended session out of the store, so a
* listed cold session necessarily has events.
*/
blank: boolean
/** fork/spawn lineage (session.header.parentSession passthrough); absent for root sessions. */
parentSessionId?: SessionId
/** Session working directory (header.cwd passthrough); absent when unrecorded. */
@@ -99,8 +108,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,27 @@
/**
* skills domain zod schemas (names derived from map keys: skillListRequestSchema /
* skillListValueSchema).
*/
import { z } from 'zod'
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
import type { Wire } from './rpc.schema.ts'
import { sessionIdSchema } from './sessions.schema.ts'
import type { SkillEntry } from './skills.ts'
/** SkillEntry row of skill.list. */
export const skillEntrySchema = z.object({
name: z.string().min(1),
description: z.string(),
whenToUse: z.string().optional(),
}) satisfies z.ZodType<Wire<SkillEntry>>
/** skill.list request payload. */
export const skillListRequestSchema = z.object({
sessionId: sessionIdSchema,
}) satisfies z.ZodType<Wire<RequestPayload<'skill.list'>>>
/** skill.list response value. */
export const skillListValueSchema = z.object({
skills: z.array(skillEntrySchema),
}) satisfies z.ZodType<Wire<ResponseValue<'skill.list'>>>

View File

@@ -0,0 +1,25 @@
/**
* skills domain contract: read-only skill catalog lookup addressed by session.
* The session's header cwd resolves to the canonical project root host-side —
* the client never submits a raw path, and skill lookup never creates or
* resumes an Agent.
*/
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { RpcRequest, RpcResponse } from './rpc.ts'
/** Skill catalog row (wire projection of the host SkillSummary; provider/source vocabulary stays host-side). */
export interface SkillEntry {
/** Kebab-case identifier referenced as `<skill>name</skill>` in prompts. */
readonly name: string
/** Short routing description. */
readonly description: string
/** Optional extra routing guidance. */
readonly whenToUse?: string
}
/** Skill-domain unary methods (the map key skill.* of RpcMethodMap). */
export interface SkillsApi {
/** Lists model-invocable skills for the addressed session's project root. */
list(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<{ skills: readonly SkillEntry[] }>>
}

View File

@@ -0,0 +1,72 @@
/**
* 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'>>>
/** workspace.rename request payload: the new title must be non-blank. */
export const workspaceRenameRequestSchema = z.object({
workspaceId: workspaceIdSchema,
title: z.string(),
}).refine(
payload => payload.title.trim() !== '',
{ message: 'workspace.rename requires a non-blank title' },
) satisfies z.ZodType<Wire<RequestPayload<'workspace.rename'>>>
/** workspace.rename response value. */
export const workspaceRenameValueSchema = z.object({
workspace: workspaceViewSchema,
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.rename'>>>
/** workspace.insertSessionBefore request payload (anchor omitted = append to end). */
export const workspaceInsertSessionBeforeRequestSchema = z.object({
workspaceId: workspaceIdSchema,
sessionId: sessionIdSchema,
beforeSessionId: sessionIdSchema.optional(),
}) satisfies z.ZodType<Wire<RequestPayload<'workspace.insertSessionBefore'>>>
/** workspace.insertSessionBefore response value. */
export const workspaceInsertSessionBeforeValueSchema = z.object({
workspace: workspaceViewSchema,
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.insertSessionBefore'>>>

View File

@@ -0,0 +1,81 @@
/**
* 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, in manually owned order
* (attach prepends, insertSessionBefore reorders; activity never does).
*/
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 }>>
/**
* Renames a workspace. `title` is trimmed and must be non-empty
* (schema-enforced). An unknown id fails with `workspace-not-found`; a
* title equal to another workspace's fails with `workspace-name-conflict`.
* Renaming to the current title is a no-op success (no durable write).
*/
rename(request: RpcRequest<{ workspaceId: WorkspaceId; title: string }>):
Promise<RpcResponse<{ workspace: WorkspaceView }>>
/**
* Moves an accounted session within its workspace's manual order,
* DOM-insertBefore-like: with `beforeSessionId` the session is inserted
* before that anchor; omitted appends to the end. An unknown workspace
* fails with `workspace-not-found`; a session or anchor not accounted by
* the workspace fails with `workspace-move-invalid`. A move to the current
* position is a no-op success.
*/
insertSessionBefore(request: RpcRequest<{
workspaceId: WorkspaceId
sessionId: SessionId
beforeSessionId?: SessionId
}>): Promise<RpcResponse<{ workspace: WorkspaceView }>>
}

View File

@@ -23,6 +23,14 @@ import {
sessionPromptValueSchema,
sessionSelectModelValueSchema,
} from '../api/sessions.schema.ts'
import {
workspaceCreateValueSchema,
workspaceInsertSessionBeforeValueSchema,
workspaceListValueSchema,
workspaceRenameValueSchema,
} from '../api/workspace.schema.ts'
import { commandExecuteValueSchema, commandListValueSchema } from '../api/commands.schema.ts'
import { skillListValueSchema } from '../api/skills.schema.ts'
/**
* Client consumption face of the contract (shape a): same domain tree as ApiProxy, but unary
@@ -52,6 +60,19 @@ 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'>>>
rename(payload: RequestPayload<'workspace.rename'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.rename'>>>
insertSessionBefore(payload: RequestPayload<'workspace.insertSessionBefore'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.insertSessionBefore'>>>
}
commands: {
list(payload: RequestPayload<'command.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'command.list'>>>
execute(payload: RequestPayload<'command.execute'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'command.execute'>>>
}
skills: {
list(payload: RequestPayload<'skill.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'skill.list'>>>
}
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>>
@@ -73,6 +94,13 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
'session.prompt': sessionPromptValueSchema,
'session.cancel': sessionCancelValueSchema,
'host.describe': hostDescribeValueSchema,
'workspace.list': workspaceListValueSchema,
'workspace.create': workspaceCreateValueSchema,
'workspace.rename': workspaceRenameValueSchema,
'workspace.insertSessionBefore': workspaceInsertSessionBeforeValueSchema,
'command.list': commandListValueSchema,
'command.execute': commandExecuteValueSchema,
'skill.list': skillListValueSchema,
}
/** Default unary timeout (rpc-compare 2026-07-19: a hung host must not leave callers pending forever). */
@@ -261,6 +289,22 @@ 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),
rename: (payload, signal) => this.callUnary('workspace.rename', payload, signal),
insertSessionBefore: (payload, signal) => this.callUnary('workspace.insertSessionBefore', payload, signal),
}
readonly commands: IApiClient['commands'] = {
list: (payload, signal) => this.callUnary('command.list', payload, signal),
execute: (payload, signal) => this.callUnary('command.execute', payload, signal),
}
readonly skills: IApiClient['skills'] = {
list: (payload, signal) => this.callUnary('skill.list', 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

@@ -24,6 +24,14 @@ import {
sessionSelectModelRequestSchema,
} from '../api/sessions.schema.ts'
import { hostDescribeRequestSchema } from '../api/host.schema.ts'
import {
workspaceCreateRequestSchema,
workspaceInsertSessionBeforeRequestSchema,
workspaceListRequestSchema,
workspaceRenameRequestSchema,
} from '../api/workspace.schema.ts'
import { commandExecuteRequestSchema, commandListRequestSchema } from '../api/commands.schema.ts'
import { skillListRequestSchema } from '../api/skills.schema.ts'
/**
* Unary dispatch table, keyed by (and compiler-locked to) RpcMethodMap: a map row without a
@@ -31,11 +39,13 @@ import { hostDescribeRequestSchema } from '../api/host.schema.ts'
* payload type — a schema pasted onto the wrong row is a type error, not a runtime surprise.
* Schemas anchor to the Wire<> widening (the repo-wide exactOptionalPropertyTypes accommodation
* documented on Wire); the dispatch point carries the one Wire→exact cast.
* Every invoke receives the carrier Request's signal; methods whose contract
* declares a signal parameter (command.execute) forward it, the rest ignore it.
*/
type UnaryRoutes = {
[K in keyof RpcMethodMap]: {
schema: z.ZodType<Wire<RequestPayload<K>>>
invoke(api: ApiProxy, request: RpcRequest<RequestPayload<K>>): Promise<RpcResponse<ResponseValue<K>>>
invoke(api: ApiProxy, request: RpcRequest<RequestPayload<K>>, signal: AbortSignal): Promise<RpcResponse<ResponseValue<K>>>
}
}
@@ -48,6 +58,13 @@ const UNARY_ROUTES: UnaryRoutes = {
'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(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) },
'workspace.rename': { schema: workspaceRenameRequestSchema, invoke: (api, r) => api.workspace.rename(r) },
'workspace.insertSessionBefore': { schema: workspaceInsertSessionBeforeRequestSchema, invoke: (api, r) => api.workspace.insertSessionBefore(r) },
'command.list': { schema: commandListRequestSchema, invoke: (api, r) => api.commands.list(r) },
'command.execute': { schema: commandExecuteRequestSchema, invoke: (api, r, signal) => api.commands.execute(r, signal) },
'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) },
}
/** Route lookup that narrows an arbitrary path segment to a map key (single cast point for the string→key refinement). */
@@ -83,14 +100,16 @@ function fullResponse(narrow: RpcResponse<unknown>): Response {
// K appears once in the signature but ties the UNARY_ROUTES[K] row lookup to its own
// schema/invoke pairing; a union parameter degrades the row to an uninvokable intersection.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
async function handleUnary<K extends keyof RpcMethodMap>(api: ApiProxy, method: K, message: ClientRequest): Promise<Response> {
async function handleUnary<K extends keyof RpcMethodMap>(
api: ApiProxy, method: K, message: ClientRequest, signal: AbortSignal,
): Promise<Response> {
const route = UNARY_ROUTES[method]
const payload = route.schema.safeParse(message.payload)
if (!payload.success) {
return errorResponse(message.rpcId, { code: 'bad-request', message: `invalid payload for ${method}`, details: { issues: payload.error.issues } })
}
try {
return fullResponse(await route.invoke(api, { rpcId: message.rpcId, payload: payload.data }))
return fullResponse(await route.invoke(api, { rpcId: message.rpcId, payload: payload.data }, signal))
} catch (error: unknown) {
// The impl never throws business errors; reaching here means the implementation itself crashed — 500, carrier layer.
return new Response(`handler failure: ${String(error)}`, { status: 500 })
@@ -195,7 +214,7 @@ export function toFetchHandler(api: ApiProxy): { fetch: typeof fetch } {
if (message.method !== method) {
return errorResponse(message.rpcId, { code: 'bad-request', message: `method "${message.method}" does not match path "${method}"`, details: { issues: [] } })
}
return handleUnary(api, method, message)
return handleUnary(api, method, message, req.signal)
},
}
}

View File

@@ -1,13 +1,85 @@
/**
* @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', '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 commands: ApiProxy['commands']
readonly skills: ApiProxy['skills']
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.commands = api.commands
this.skills = api.skills
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 = () => {}