mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge origin/master into goal-ui: adopt the rewritten client core and apiproxy carrier
Conflict rulings follow the projection-reattach plan: - host/runtime package (deleted on master): take master; the PR's boot composition moves to the cordis.yml roster and its goals handlers will be re-landed in dsh-host-apiproxy; the session.prompt slash interception and its spec are dropped entirely (superseded by command.execute + command/run logging). - client core (rewritten on master): take master; the PR's Session goal fields/methods, ConversationSnapshot.goal, goalActions injection, and the hard-mounted GoalBar are all superseded by the 'goal' session projection (useProjection) and will return as the ui-goal plugin. - wire contract: union of master's workspace/command/skill domains and the PR's goal domain, minus goal.get (the read side is the projection block + session/projection frames; six mutation RPCs stay). - GoalBar component and spec leave ui-conversation (they re-land in the new ui-goal package); IconSparkle16 stays in ui-conversation chat. - The web-slash-command-dispatch note documents the dropped interception and is removed; the goal-bar note will be rewritten for the projection model. - pnpm-lock.yaml taken from master (reinstall recomputes).
This commit is contained in:
1303
packages/host/apiproxy/src/api-proxy.ts
Normal file
1303
packages/host/apiproxy/src/api-proxy.ts
Normal file
File diff suppressed because it is too large
Load Diff
44
packages/host/apiproxy/src/api/commands.schema.ts
Normal file
44
packages/host/apiproxy/src/api/commands.schema.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* commands domain zod schemas (names derived from map keys: commandListRequestSchema /
|
||||
* commandListValueSchema / commandExecuteRequestSchema / commandExecuteValueSchema).
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
|
||||
import type { Wire } from './rpc.schema.ts'
|
||||
import { sessionIdSchema } from './sessions.schema.ts'
|
||||
import type { CommandDescriptor } 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'>>>
|
||||
|
||||
/** CommandId: one brand cast after shape validation (the only cast point in this domain). */
|
||||
export const commandIdSchema = z.string().min(1) as unknown as z.ZodType<CommandId>
|
||||
|
||||
/** command.execute response value: pure admission — outcomes ride the logged
|
||||
* lifecycle events; commandId (present exactly when matched) correlates with them. */
|
||||
export const commandExecuteValueSchema = z.object({
|
||||
matched: z.boolean(),
|
||||
commandId: commandIdSchema.optional(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'command.execute'>>>
|
||||
48
packages/host/apiproxy/src/api/commands.ts
Normal file
48
packages/host/apiproxy/src/api/commands.ts
Normal 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 { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
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 }
|
||||
}
|
||||
|
||||
/** 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 — pure admission semantics. matched=false
|
||||
* when syntax or name does not resolve (the client falls back to its
|
||||
* default sink). The handler's outcome does NOT ride the response: the host
|
||||
* executor durably logs the lifecycle (`command/run`/`command/done`), which
|
||||
* broadcasts on the mux stream and renders as a persistent flow node.
|
||||
* `commandId` is present exactly when matched — the minted lifecycle
|
||||
* pairing id, letting the issuing client correlate this acknowledgment
|
||||
* with that flow node. 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; commandId?: CommandId }>>
|
||||
}
|
||||
@@ -10,33 +10,53 @@ 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 { workspaceIdSchema, workspaceViewSchema } from './workspace.schema.ts'
|
||||
|
||||
/** Question shape validated strictly against core dsh-user-interaction. */
|
||||
export const askUserQuestionItemSchema = z.object({
|
||||
id: z.string(),
|
||||
question: z.string(),
|
||||
header: z.string().optional(),
|
||||
detail: z.string().optional(),
|
||||
options: z.array(z.object({ label: z.string(), description: z.string().optional() })).optional(),
|
||||
multiSelect: z.boolean().optional(),
|
||||
}) satisfies z.ZodType<Wire<AskUserQuestionItem>>
|
||||
|
||||
/** Unified message envelope carried by transient queue frames. */
|
||||
const messageSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
role: z.union([z.literal('system'), z.literal('user'), z.literal('assistant')]),
|
||||
content: z.array(contentBlockSchema),
|
||||
source: z.looseObject({ kind: z.string() }),
|
||||
})
|
||||
|
||||
/** MuxFrame union (payload slot of a mux-stream ServerRequest). */
|
||||
export const muxFrameSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('session/event'), sessionId: sessionIdSchema, event: sessionEventSchema, view: toolEventViewSchema.optional() }),
|
||||
z.object({ type: z.literal('session/subscribed'), sessionId: sessionIdSchema, lastSeq: z.number().int() }),
|
||||
z.object({ type: z.literal('approval/requested'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, toolName: z.string(), callId: z.string().optional(), reason: z.string().optional() }),
|
||||
z.object({ type: z.literal('approval/resolved'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, outcome: z.union([z.literal('allowed-once'), z.literal('rejected'), z.literal('cancelled'), z.literal('unavailable')]) }),
|
||||
z.object({ type: z.literal('question/requested'), sessionId: sessionIdSchema, questions: z.array(askUserQuestionItemSchema) }),
|
||||
// Non-empty by wire contract: the user-interaction service rejects empty
|
||||
// batches at ask() (EMPTY_QUESTIONS), so an empty frame is host breakage
|
||||
// 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')]) }),
|
||||
z.object({ type: z.literal('session/queued'), sessionId: sessionIdSchema, message: messageSchema, steering: z.boolean() }),
|
||||
// value stays wide: it already passed its unit's own schema on the host,
|
||||
// and deep-validating here would import every domain's schema into the carrier.
|
||||
z.object({ type: z.literal('session/projection'), sessionId: sessionIdSchema, key: z.string().min(1), value: z.unknown(), seq: z.number().int().nonnegative() }),
|
||||
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/workspace-removed'), workspaceId: workspaceIdSchema }),
|
||||
z.object({ type: z.literal('host/commands-changed') }),
|
||||
z.object({ type: z.literal('stream/error'), error: rpcErrorSchema }),
|
||||
]) as unknown as z.ZodType<HostFrame>
|
||||
|
||||
@@ -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 { Message } 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.
|
||||
@@ -33,8 +35,9 @@ export type ToolEventView =
|
||||
export interface EventsApi {
|
||||
/**
|
||||
* All-session aggregated mux stream. On open, emits a subscribed control frame for every
|
||||
* attached session and replays each session's still-pending approval/question requested
|
||||
* frames (rpcId reused verbatim — the refresh-recovery baseline).
|
||||
* attached session, then replays each session's still-pending approval/question requested
|
||||
* frames (rpcId reused verbatim — the refresh-recovery baseline). Session titles ride the
|
||||
* generic projection pair (history-tail projections block + session/projection frames).
|
||||
* since: resume seam, unimplemented in v1 (ignored if passed); reconnection = reopen the
|
||||
* stream + refetch history.
|
||||
*/
|
||||
@@ -58,12 +61,55 @@ 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. A queued message is not
|
||||
* model-visible, so there is no session event to carry it; 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.
|
||||
* `steering` is the host's acceptance-time queue classification and remains
|
||||
* authoritative in reconnect snapshots. `message.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; message: Message; steering: boolean }
|
||||
/**
|
||||
* One projection unit's finished value changed (session-projection RFC).
|
||||
* Live push state, never logged — replay recomputes on the host (the
|
||||
* tool-view posture). `value` is the unit's schema-validated view output;
|
||||
* `seq` is the unit's watermark at emission. Clients keep one generic
|
||||
* per-session value store under higher-seq-wins, seeded by the history
|
||||
* tail page's projections block.
|
||||
*/
|
||||
| { type: 'session/projection'; sessionId: SessionId; key: string; value: unknown; seq: number }
|
||||
| { 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); workspace-removed is the
|
||||
* committed registration-deletion increment and never implies directory or
|
||||
* session-log deletion.
|
||||
*/
|
||||
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 }
|
||||
| { type: 'host/workspace-removed'; workspaceId: WorkspaceView['workspaceId'] }
|
||||
/**
|
||||
* 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 }
|
||||
|
||||
@@ -32,16 +32,6 @@ export const goalViewSchema = z.object({
|
||||
activation: z.union([z.literal('armed'), z.literal('disarmed')]),
|
||||
}) as unknown as z.ZodType<Wire<GoalView>>
|
||||
|
||||
/** goal.get request payload. */
|
||||
export const goalGetRequestSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
}) as unknown as z.ZodType<Wire<RequestPayload<'goal.get'>>>
|
||||
|
||||
/** goal.get response value. */
|
||||
export const goalGetValueSchema = z.object({
|
||||
goal: goalViewSchema.nullable(),
|
||||
}) as unknown as z.ZodType<Wire<ResponseValue<'goal.get'>>>
|
||||
|
||||
/** goal.create request payload. */
|
||||
export const goalCreateRequestSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
|
||||
@@ -58,11 +58,8 @@ export interface EditGoalRequest {
|
||||
readonly maxGoalRounds?: number
|
||||
}
|
||||
|
||||
/** Goal-domain unary methods. */
|
||||
/** Goal-domain unary methods (mutations only: the read side is the 'goal' session projection). */
|
||||
export interface GoalsApi {
|
||||
/** Read the current goal for one session. Returns null when no goal is current. */
|
||||
get(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<{ goal: GoalView | null }>>
|
||||
|
||||
/** Create and arm a goal. */
|
||||
create(request: RpcRequest<{ sessionId: SessionId; objective: string; maxGoalRounds?: number }>):
|
||||
Promise<RpcResponse<{ goal: GoalView }>>
|
||||
|
||||
@@ -17,3 +17,21 @@ export const hostDescribeValueSchema = z.object({
|
||||
model: z.string().optional(),
|
||||
attachedSessions: z.number().int().nonnegative(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'host.describe'>>>
|
||||
|
||||
/** host.pickDirectory request payload (empty object literal). */
|
||||
export const hostPickDirectoryRequestSchema = z.object({}) satisfies z.ZodType<Wire<RequestPayload<'host.pickDirectory'>>>
|
||||
|
||||
/** host.pickDirectory response value; null means the user cancelled. */
|
||||
export const hostPickDirectoryValueSchema = z.object({
|
||||
path: z.string().nullable(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'host.pickDirectory'>>>
|
||||
|
||||
/** host.openPath request payload. */
|
||||
export const hostOpenPathRequestSchema = z.object({
|
||||
path: z.string().min(1),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'host.openPath'>>>
|
||||
|
||||
/** host.openPath response value. */
|
||||
export const hostOpenPathValueSchema = z.object({
|
||||
opened: z.literal(true),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'host.openPath'>>>
|
||||
|
||||
@@ -22,4 +22,20 @@ export interface HostApi {
|
||||
model?: string
|
||||
attachedSessions: number
|
||||
}>>
|
||||
|
||||
/** Open the operating system's single-directory picker; cancellation returns null. */
|
||||
pickDirectory(
|
||||
request: RpcRequest<{}>,
|
||||
signal: AbortSignal,
|
||||
): Promise<RpcResponse<{ path: string | null }>>
|
||||
|
||||
/**
|
||||
* Open a filesystem path with the operating system's default application
|
||||
* (Finder / Explorer / xdg-open hand-off). The browser carrier restricts this
|
||||
* privileged method to loopback, same-origin requests.
|
||||
*/
|
||||
openPath(
|
||||
request: RpcRequest<{ path: string }>,
|
||||
signal: AbortSignal,
|
||||
): Promise<RpcResponse<{ opened: true }>>
|
||||
}
|
||||
|
||||
@@ -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 { GoalsApi } from './goals.ts'
|
||||
import type { ClientResponse, RpcReceipt } from './rpc.ts'
|
||||
@@ -14,6 +17,9 @@ import type { ClientResponse, RpcReceipt } from './rpc.ts'
|
||||
export interface ApiProxy {
|
||||
sessions: SessionsApi
|
||||
host: HostApi
|
||||
workspace: WorkspaceApi
|
||||
commands: CommandsApi
|
||||
skills: SkillsApi
|
||||
events: EventsApi
|
||||
goals: GoalsApi
|
||||
/** Response entry for server-requests (client-response, echoing their rpcId); not a domain method (four-quadrant model). */
|
||||
@@ -21,8 +27,14 @@ export interface ApiProxy {
|
||||
}
|
||||
|
||||
// ---- Domain interfaces and payload entities ----
|
||||
export type { HistoryEntry, SessionsApi, SessionSummary } from './sessions.ts'
|
||||
export type {
|
||||
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
ModelReasoningEffort, ModelTarget, SessionModels, SessionProjectionsBlock, SessionsApi, SessionSummary,
|
||||
} from './sessions.ts'
|
||||
export type { HostApi } from './host.ts'
|
||||
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
|
||||
export type { CommandsApi, CommandDescriptor } from './commands.ts'
|
||||
export type { SkillsApi, SkillEntry } from './skills.ts'
|
||||
export type { EventsApi, MuxFrame, HostFrame, ToolCallView, ToolEventView, ToolResultView } from './events.ts'
|
||||
export type { GoalsApi, GoalView, GoalRef, GoalPhase, GoalBlockReason, CreateGoalRequest, EditGoalRequest } from './goals.ts'
|
||||
export type { ApprovalResponsePayload } from './approvals.ts'
|
||||
@@ -42,7 +54,7 @@ export type {
|
||||
} from './rpc.ts'
|
||||
|
||||
// ---- Errors and ids ----
|
||||
export { RpcId } from './rpc.ts'
|
||||
export { RpcId, transportError } from './rpc.ts'
|
||||
export type { RpcError, RpcErrorCode, RpcErrorDetailsMap, RpcResult } from './rpc.ts'
|
||||
|
||||
// ---- Method registry and derived generics ----
|
||||
|
||||
@@ -6,18 +6,36 @@
|
||||
|
||||
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 { GoalsApi } from './goals.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']
|
||||
'session.history': SessionsApi['history']
|
||||
'session.models': SessionsApi['models']
|
||||
'session.selectModel': SessionsApi['selectModel']
|
||||
'session.prompt': SessionsApi['prompt']
|
||||
'session.cancel': SessionsApi['cancel']
|
||||
'host.describe': HostApi['describe']
|
||||
'goal.get': GoalsApi['get']
|
||||
'host.pickDirectory': HostApi['pickDirectory']
|
||||
'host.openPath': HostApi['openPath']
|
||||
'workspace.list': WorkspaceApi['list']
|
||||
'workspace.create': WorkspaceApi['create']
|
||||
'workspace.rename': WorkspaceApi['rename']
|
||||
'workspace.delete': WorkspaceApi['delete']
|
||||
'workspace.insertSessionBefore': WorkspaceApi['insertSessionBefore']
|
||||
'command.list': CommandsApi['list']
|
||||
'command.execute': CommandsApi['execute']
|
||||
'skill.list': SkillsApi['list']
|
||||
'goal.create': GoalsApi['create']
|
||||
'goal.edit': GoalsApi['edit']
|
||||
'goal.pause': GoalsApi['pause']
|
||||
|
||||
@@ -33,7 +33,15 @@ export const rpcIdSchema = z.string() as unknown as z.ZodType<RpcId>
|
||||
/** Error body: discriminated by code, per-branch details aligned to RpcErrorDetailsMap; details is required. */
|
||||
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('command-error'), message: z.string(), details: z.object({}) }),
|
||||
z.object({ code: z.literal('unknown-command'), message: z.string(), details: z.object({}) }),
|
||||
|
||||
@@ -30,7 +30,15 @@ export function RpcId(id: string): RpcId {
|
||||
/** Error code → details type map (a second table isomorphic to RpcMethodMap). New code = one row here + one branch in the error schema. */
|
||||
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 }
|
||||
/** A known slash command reported a usage/state error; the message is the command's own text. */
|
||||
'command-error': {}
|
||||
@@ -53,6 +61,20 @@ export type RpcError = {
|
||||
/** Business success/failure result: the result slot of a unary response; methods never throw business errors. */
|
||||
export type RpcResult<T> = { ok: true; value: T } | { ok: false; error: RpcError }
|
||||
|
||||
/**
|
||||
* Fold a transport exception into the RpcResult error branch (unified error
|
||||
* surface; 'internal' as the catch-all code). Lives with RpcResult so every
|
||||
* carrier consumer folds the same way.
|
||||
* @param error - the thrown value from the carrier.
|
||||
* @returns the error branch of an RpcResult.
|
||||
*/
|
||||
export function transportError<T>(error: unknown): RpcResult<T> {
|
||||
return {
|
||||
ok: false,
|
||||
error: { code: 'internal', message: error instanceof Error ? error.message : String(error), details: {} },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Signature-layer narrow form, request side (domain-interface view, shared by
|
||||
* both directions): rpcId is explicit in the signature, never mixed into the
|
||||
|
||||
@@ -9,12 +9,24 @@ import { z } from 'zod'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
|
||||
import type { Wire } from './rpc.schema.ts'
|
||||
import type { HistoryEntry, SessionSummary } from './sessions.ts'
|
||||
import type {
|
||||
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
ModelReasoningEffort, ModelTarget, SessionProjectionsBlock, 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(),
|
||||
@@ -30,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>>
|
||||
@@ -44,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({
|
||||
@@ -61,6 +79,49 @@ export const sessionHistoryRequestSchema = z.object({
|
||||
maxMessages: z.number().int().positive().optional(),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'session.history'>>>
|
||||
|
||||
/** Complete provider/model target. */
|
||||
export const modelTargetSchema = z.object({
|
||||
provider: z.string().min(1),
|
||||
model: z.string().min(1),
|
||||
reasoningEffort: z.string().min(1).optional(),
|
||||
}) satisfies z.ZodType<Wire<ModelTarget>>
|
||||
|
||||
/** One adapter-owned reasoning effort. */
|
||||
export const modelReasoningEffortSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
}) satisfies z.ZodType<Wire<ModelReasoningEffort>>
|
||||
|
||||
/** Exact-model reasoning metadata. */
|
||||
export const modelReasoningSchema = z.object({
|
||||
efforts: z.array(modelReasoningEffortSchema).min(1),
|
||||
defaultEffort: z.string().min(1).optional(),
|
||||
}) satisfies z.ZodType<Wire<ModelReasoning>>
|
||||
|
||||
/** One advisory model entry inside a provider group. */
|
||||
export const modelCatalogModelSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
unlisted: z.literal(true).optional(),
|
||||
reasoning: modelReasoningSchema.optional(),
|
||||
}) satisfies z.ZodType<Wire<ModelCatalogModel>>
|
||||
|
||||
/** One successfully loaded provider group. */
|
||||
export const modelProviderGroupSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
models: z.array(modelCatalogModelSchema),
|
||||
}) satisfies z.ZodType<Wire<ModelProviderGroup>>
|
||||
|
||||
/** One provider-local catalog failure. */
|
||||
export const modelCatalogFailureSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
message: z.string(),
|
||||
}) satisfies z.ZodType<Wire<ModelCatalogFailure>>
|
||||
|
||||
/**
|
||||
* ToolEventView passthrough: lock only the `for` discriminant and the presence
|
||||
* of a card-tagged `view` object. The view interior is a host-computed product
|
||||
@@ -78,12 +139,49 @@ export const historyEntrySchema = z.object({
|
||||
view: toolEventViewSchema.optional(),
|
||||
}) satisfies z.ZodType<Wire<HistoryEntry>>
|
||||
|
||||
/** session.history response value. */
|
||||
/**
|
||||
* Projection baseline passthrough: `values` stays a wide record — each value
|
||||
* was already parsed by its provider's own schema on the host side, and
|
||||
* deep-validating here would import every domain's schema into the carrier.
|
||||
*/
|
||||
export const sessionProjectionsBlockSchema = z.object({
|
||||
// -1 = empty log (the lastSeq convention of session/subscribed).
|
||||
asOfSeq: z.number().int().min(-1),
|
||||
values: z.record(z.string(), z.unknown()),
|
||||
}) as unknown as z.ZodType<SessionProjectionsBlock>
|
||||
|
||||
/** session.history response value (projections rides the tail page only). */
|
||||
export const sessionHistoryValueSchema = z.object({
|
||||
events: z.array(historyEntrySchema),
|
||||
hasMore: z.boolean(),
|
||||
projections: sessionProjectionsBlockSchema.optional(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'session.history'>>>
|
||||
|
||||
/** session.models request payload. */
|
||||
export const sessionModelsRequestSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'session.models'>>>
|
||||
|
||||
/** session.models response value. */
|
||||
export const sessionModelsValueSchema = z.object({
|
||||
current: modelTargetSchema,
|
||||
groups: z.array(modelProviderGroupSchema),
|
||||
failures: z.array(modelCatalogFailureSchema),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'session.models'>>>
|
||||
|
||||
/** session.selectModel request payload. */
|
||||
export const sessionSelectModelRequestSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
provider: z.string().min(1),
|
||||
model: z.string().min(1),
|
||||
reasoningEffort: z.string().min(1).optional(),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'session.selectModel'>>>
|
||||
|
||||
/** session.selectModel response value. */
|
||||
export const sessionSelectModelValueSchema = z.object({
|
||||
selected: modelTargetSchema,
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'session.selectModel'>>>
|
||||
|
||||
/** ContentBlock passthrough: core is merge-extensible — the type discriminant envelope is strict, the rest stays wide. */
|
||||
export const contentBlockSchema = z.looseObject({ type: z.string() })
|
||||
|
||||
|
||||
@@ -6,8 +6,12 @@
|
||||
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
// The pure-type outlet: api/ is browser-importable, and the package root's
|
||||
// cordis Context merge (via dsh-agent) must not enter client aggregates.
|
||||
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/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 {
|
||||
@@ -31,6 +35,95 @@ export interface HistoryEntry {
|
||||
view?: ToolEventView
|
||||
}
|
||||
|
||||
/**
|
||||
* The projection baseline riding the history tail page: one synchronous cut
|
||||
* over every registered projection unit, read from the registry's watermark
|
||||
* cache. `asOfSeq` is the seq of the last committed event every value
|
||||
* reflects — the window tail event seq (`-1` for an empty log, mirroring
|
||||
* `session/subscribed.lastSeq`), directly comparable with
|
||||
* `session/projection` frame seqs under the client's higher-seq-wins rule. A
|
||||
* key absent from `values` means the capability is absent (its domain plugin
|
||||
* is unmounted).
|
||||
*/
|
||||
export interface SessionProjectionsBlock {
|
||||
/** Seq of the last event the values reflect; -1 for an empty log. */
|
||||
asOfSeq: number
|
||||
/** Whole current value per registered projection key. */
|
||||
values: Partial<SessionProjectionMap>
|
||||
}
|
||||
|
||||
/** Complete model target selected for one session. */
|
||||
export interface ModelTarget {
|
||||
/** Registered provider route. */
|
||||
provider: string
|
||||
/** Provider-owned model id. */
|
||||
model: string
|
||||
/** Adapter-owned reasoning effort; absence preserves adapter/provider default behavior. */
|
||||
reasoningEffort?: string
|
||||
}
|
||||
|
||||
/** One adapter-owned reasoning effort displayed for an exact model route. */
|
||||
export interface ModelReasoningEffort {
|
||||
/** Opaque value submitted back to the owning adapter. */
|
||||
id: string
|
||||
/** Adapter-supplied display name. */
|
||||
name: string
|
||||
/** Optional adapter-supplied description. */
|
||||
description?: string
|
||||
}
|
||||
|
||||
/** Selectable reasoning metadata for one exact model route. */
|
||||
export interface ModelReasoning {
|
||||
/** Efforts in adapter-preferred display order. */
|
||||
efforts: ModelReasoningEffort[]
|
||||
/** Adapter-configured default; absence preserves the provider default. */
|
||||
defaultEffort?: string
|
||||
}
|
||||
|
||||
/** One model displayed inside its provider group. */
|
||||
export interface ModelCatalogModel {
|
||||
/** Provider-owned model id. */
|
||||
id: string
|
||||
/** Provider-supplied display name. */
|
||||
name: string
|
||||
/** Optional provider-supplied description. */
|
||||
description?: string
|
||||
/** The current model was inserted because the advisory catalog omitted it. */
|
||||
unlisted?: true
|
||||
/** Exact-route reasoning metadata when the adapter exposes it. */
|
||||
reasoning?: ModelReasoning
|
||||
}
|
||||
|
||||
/** One provider and the models it advertised successfully. */
|
||||
export interface ModelProviderGroup {
|
||||
/** Provider route id used for requests. */
|
||||
id: string
|
||||
/** Provider display name. */
|
||||
name: string
|
||||
/** Models in provider-preferred order. */
|
||||
models: ModelCatalogModel[]
|
||||
}
|
||||
|
||||
/** A provider whose asynchronous catalog lookup failed. */
|
||||
export interface ModelCatalogFailure {
|
||||
/** Provider route id. */
|
||||
id: string
|
||||
/** Provider display name. */
|
||||
name: string
|
||||
/** Lookup failure diagnostic. */
|
||||
message: string
|
||||
}
|
||||
|
||||
/** Detached model-directory snapshot for one session. */
|
||||
export interface SessionModels {
|
||||
/** Target selected for the session's next assembled step. */
|
||||
current: ModelTarget
|
||||
/** Successfully loaded provider groups. */
|
||||
groups: ModelProviderGroup[]
|
||||
/** Provider-local failures; successful groups remain usable. */
|
||||
failures: ModelCatalogFailure[]
|
||||
}
|
||||
|
||||
/** Session list entry (v1 builds no index: list does readdir+stat). */
|
||||
export interface SessionSummary {
|
||||
sessionId: SessionId
|
||||
@@ -38,6 +131,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. */
|
||||
@@ -49,8 +150,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 =
|
||||
@@ -60,9 +169,30 @@ export interface SessionsApi {
|
||||
* Each entry pairs the raw SessionEvent with the host-computed view (tool events whose
|
||||
* presenter produced one, evaluated against the registry at pagination time); the client
|
||||
* rebuilds the surface from the events with the shared fold.
|
||||
* The tail page — and only the tail page — additionally carries `projections`
|
||||
* when the deployment mounts the session-projection registry: every moment
|
||||
* the client needs a fresh baseline already pulls the tail page, and
|
||||
* loadOlder (the only beforeSeq path) is the only path that never needs one.
|
||||
* A deployment without the registry serves histories without the block.
|
||||
*/
|
||||
history(request: RpcRequest<{ sessionId: SessionId; beforeSeq?: number; maxMessages?: number }>):
|
||||
Promise<RpcResponse<{ events: HistoryEntry[]; hasMore: boolean }>>
|
||||
Promise<RpcResponse<{ events: HistoryEntry[]; hasMore: boolean; projections?: SessionProjectionsBlock }>>
|
||||
|
||||
/** Reads a fresh advisory model directory for this session. Provider lookups run independently. */
|
||||
models(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<SessionModels>>
|
||||
|
||||
/**
|
||||
* Selects the complete target for this session. Exact model metadata
|
||||
* validates an optional reasoning effort, while catalog membership remains
|
||||
* advisory.
|
||||
*/
|
||||
selectModel(request: RpcRequest<{
|
||||
sessionId: SessionId
|
||||
provider: string
|
||||
model: string
|
||||
reasoningEffort?: string
|
||||
}>):
|
||||
Promise<RpcResponse<{ selected: ModelTarget }>>
|
||||
|
||||
/**
|
||||
* Sends a message. content is core's ContentBlock[] verbatim; mode maps 1:1 — queue→send, steer→steer.
|
||||
|
||||
27
packages/host/apiproxy/src/api/skills.schema.ts
Normal file
27
packages/host/apiproxy/src/api/skills.schema.ts
Normal 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'>>>
|
||||
25
packages/host/apiproxy/src/api/skills.ts
Normal file
25
packages/host/apiproxy/src/api/skills.ts
Normal 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[] }>>
|
||||
}
|
||||
82
packages/host/apiproxy/src/api/workspace.schema.ts
Normal file
82
packages/host/apiproxy/src/api/workspace.schema.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 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.delete request payload. */
|
||||
export const workspaceDeleteRequestSchema = z.object({
|
||||
workspaceId: workspaceIdSchema,
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'workspace.delete'>>>
|
||||
|
||||
/** workspace.delete response value. */
|
||||
export const workspaceDeleteValueSchema = z.object({
|
||||
deleted: z.literal(true),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.delete'>>>
|
||||
|
||||
/** 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'>>>
|
||||
89
packages/host/apiproxy/src/api/workspace.ts
Normal file
89
packages/host/apiproxy/src/api/workspace.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 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 }>>
|
||||
|
||||
/**
|
||||
* Removes one Workspace registration. The directory, every user file, and
|
||||
* every session log remain untouched; those Sessions consequently become
|
||||
* ungrouped. An unknown id fails with `workspace-not-found`.
|
||||
*/
|
||||
delete(request: RpcRequest<{ workspaceId: WorkspaceId }>):
|
||||
Promise<RpcResponse<{ deleted: true }>>
|
||||
|
||||
/**
|
||||
* 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 }>>
|
||||
}
|
||||
@@ -13,16 +13,28 @@ import { RpcId } from '../api/rpc.ts'
|
||||
import type { Wire } from '../api/rpc.schema.ts'
|
||||
import { rpcReceiptSchema, serverRequestSchema, serverResponseSchema } from '../api/rpc.schema.ts'
|
||||
import { hostFrameSchema, muxFrameSchema } from '../api/events.schema.ts'
|
||||
import { hostDescribeValueSchema } from '../api/host.schema.ts'
|
||||
import {
|
||||
hostDescribeValueSchema, hostOpenPathValueSchema, hostPickDirectoryValueSchema,
|
||||
} from '../api/host.schema.ts'
|
||||
import {
|
||||
sessionCancelValueSchema,
|
||||
sessionCreateValueSchema,
|
||||
sessionHistoryValueSchema,
|
||||
sessionListValueSchema,
|
||||
sessionModelsValueSchema,
|
||||
sessionPromptValueSchema,
|
||||
sessionSelectModelValueSchema,
|
||||
} from '../api/sessions.schema.ts'
|
||||
import {
|
||||
goalGetValueSchema,
|
||||
workspaceCreateValueSchema,
|
||||
workspaceDeleteValueSchema,
|
||||
workspaceInsertSessionBeforeValueSchema,
|
||||
workspaceListValueSchema,
|
||||
workspaceRenameValueSchema,
|
||||
} from '../api/workspace.schema.ts'
|
||||
import { commandExecuteValueSchema, commandListValueSchema } from '../api/commands.schema.ts'
|
||||
import { skillListValueSchema } from '../api/skills.schema.ts'
|
||||
import {
|
||||
goalCreateValueSchema,
|
||||
goalEditValueSchema,
|
||||
goalPauseValueSchema,
|
||||
@@ -51,11 +63,29 @@ export interface IApiClient {
|
||||
list(payload: RequestPayload<'session.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.list'>>>
|
||||
create(payload: RequestPayload<'session.create'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.create'>>>
|
||||
history(payload: RequestPayload<'session.history'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.history'>>>
|
||||
models(payload: RequestPayload<'session.models'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.models'>>>
|
||||
selectModel(payload: RequestPayload<'session.selectModel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.selectModel'>>>
|
||||
prompt(payload: RequestPayload<'session.prompt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.prompt'>>>
|
||||
cancel(payload: RequestPayload<'session.cancel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.cancel'>>>
|
||||
}
|
||||
host: {
|
||||
describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.describe'>>>
|
||||
pickDirectory(payload: RequestPayload<'host.pickDirectory'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.pickDirectory'>>>
|
||||
openPath(payload: RequestPayload<'host.openPath'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.openPath'>>>
|
||||
}
|
||||
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'>>>
|
||||
delete(payload: RequestPayload<'workspace.delete'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.delete'>>>
|
||||
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>>
|
||||
@@ -82,10 +112,21 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
|
||||
'session.list': sessionListValueSchema,
|
||||
'session.create': sessionCreateValueSchema,
|
||||
'session.history': sessionHistoryValueSchema,
|
||||
'session.models': sessionModelsValueSchema,
|
||||
'session.selectModel': sessionSelectModelValueSchema,
|
||||
'session.prompt': sessionPromptValueSchema,
|
||||
'session.cancel': sessionCancelValueSchema,
|
||||
'host.describe': hostDescribeValueSchema,
|
||||
'goal.get': goalGetValueSchema,
|
||||
'host.pickDirectory': hostPickDirectoryValueSchema,
|
||||
'host.openPath': hostOpenPathValueSchema,
|
||||
'workspace.list': workspaceListValueSchema,
|
||||
'workspace.create': workspaceCreateValueSchema,
|
||||
'workspace.rename': workspaceRenameValueSchema,
|
||||
'workspace.delete': workspaceDeleteValueSchema,
|
||||
'workspace.insertSessionBefore': workspaceInsertSessionBeforeValueSchema,
|
||||
'command.list': commandListValueSchema,
|
||||
'command.execute': commandExecuteValueSchema,
|
||||
'skill.list': skillListValueSchema,
|
||||
'goal.create': goalCreateValueSchema,
|
||||
'goal.edit': goalEditValueSchema,
|
||||
'goal.pause': goalPauseValueSchema,
|
||||
@@ -171,13 +212,22 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
* Shared POST leg of both C→S carriers (callUnary/respond): JSON body,
|
||||
* timeout merged with the caller's optional external signal, non-2xx → transport throw.
|
||||
*/
|
||||
private async postJson(path: string, body: ClientRequest | ClientResponse, signal: AbortSignal | undefined): Promise<Response> {
|
||||
const timeout = AbortSignal.timeout(this.timeoutMs)
|
||||
private async postJson(
|
||||
path: string,
|
||||
body: ClientRequest | ClientResponse,
|
||||
signal: AbortSignal | undefined,
|
||||
useDefaultTimeout = true,
|
||||
): Promise<Response> {
|
||||
const requestSignal = useDefaultTimeout
|
||||
? signal === undefined
|
||||
? AbortSignal.timeout(this.timeoutMs)
|
||||
: AbortSignal.any([AbortSignal.timeout(this.timeoutMs), signal])
|
||||
: signal
|
||||
const response = await this.doFetch(new URL(path, this.resolveBase()), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: signal === undefined ? timeout : AbortSignal.any([timeout, signal]),
|
||||
...requestSignal === undefined ? {} : { signal: requestSignal },
|
||||
})
|
||||
if (!response.ok) throw new Error(`transport failure for ${path}: HTTP ${response.status}`)
|
||||
return response
|
||||
@@ -192,10 +242,11 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
method: K,
|
||||
payload: RequestPayload<K>,
|
||||
signal?: AbortSignal,
|
||||
useDefaultTimeout = true,
|
||||
): Promise<RpcResponse<ResponseValue<K>>> {
|
||||
const message: ClientRequest = { type: 'client-request', rpcId: this.mintRpcId(), method, payload }
|
||||
this.onEnvelope(message)
|
||||
const response = await this.postJson(`/api/${method}`, message, signal)
|
||||
const response = await this.postJson(`/api/${method}`, message, signal, useDefaultTimeout)
|
||||
const full = serverResponseSchema.parse(await response.json())
|
||||
this.onEnvelope(full)
|
||||
if (full.rpcId !== message.rpcId) throw new Error(`rpcId mismatch for ${method}: sent ${message.rpcId}, got ${full.rpcId}`)
|
||||
@@ -270,12 +321,35 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
list: (payload, signal) => this.callUnary('session.list', payload, signal),
|
||||
create: (payload, signal) => this.callUnary('session.create', payload, signal),
|
||||
history: (payload, signal) => this.callUnary('session.history', payload, signal),
|
||||
models: (payload, signal) => this.callUnary('session.models', payload, signal),
|
||||
selectModel: (payload, signal) => this.callUnary('session.selectModel', payload, signal),
|
||||
prompt: (payload, signal) => this.callUnary('session.prompt', payload, signal),
|
||||
cancel: (payload, signal) => this.callUnary('session.cancel', payload, signal),
|
||||
}
|
||||
|
||||
readonly host: IApiClient['host'] = {
|
||||
describe: (payload, signal) => this.callUnary('host.describe', payload, signal),
|
||||
// A native system dialog is user-paced and may legitimately stay open
|
||||
// longer than the normal unary deadline. Caller/connection aborts remain.
|
||||
pickDirectory: (payload, signal) => this.callUnary('host.pickDirectory', payload, signal, false),
|
||||
openPath: (payload, signal) => this.callUnary('host.openPath', 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),
|
||||
delete: (payload, signal) => this.callUnary('workspace.delete', 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 goals: IApiClient['goals'] = {
|
||||
|
||||
@@ -19,11 +19,23 @@ import {
|
||||
sessionCreateRequestSchema,
|
||||
sessionHistoryRequestSchema,
|
||||
sessionListRequestSchema,
|
||||
sessionModelsRequestSchema,
|
||||
sessionPromptRequestSchema,
|
||||
sessionSelectModelRequestSchema,
|
||||
} from '../api/sessions.schema.ts'
|
||||
import { hostDescribeRequestSchema } from '../api/host.schema.ts'
|
||||
import {
|
||||
goalGetRequestSchema,
|
||||
hostDescribeRequestSchema, hostOpenPathRequestSchema, hostPickDirectoryRequestSchema,
|
||||
} from '../api/host.schema.ts'
|
||||
import {
|
||||
workspaceCreateRequestSchema,
|
||||
workspaceDeleteRequestSchema,
|
||||
workspaceInsertSessionBeforeRequestSchema,
|
||||
workspaceListRequestSchema,
|
||||
workspaceRenameRequestSchema,
|
||||
} from '../api/workspace.schema.ts'
|
||||
import { commandExecuteRequestSchema, commandListRequestSchema } from '../api/commands.schema.ts'
|
||||
import { skillListRequestSchema } from '../api/skills.schema.ts'
|
||||
import {
|
||||
goalCreateRequestSchema,
|
||||
goalEditRequestSchema,
|
||||
goalPauseRequestSchema,
|
||||
@@ -38,11 +50,13 @@ import {
|
||||
* 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>>>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,10 +64,21 @@ const UNARY_ROUTES: UnaryRoutes = {
|
||||
'session.list': { schema: sessionListRequestSchema, invoke: (api, r) => api.sessions.list(r) },
|
||||
'session.create': { schema: sessionCreateRequestSchema, invoke: (api, r) => api.sessions.create(r) },
|
||||
'session.history': { schema: sessionHistoryRequestSchema, invoke: (api, r) => api.sessions.history(r) },
|
||||
'session.models': { schema: sessionModelsRequestSchema, invoke: (api, r) => api.sessions.models(r) },
|
||||
'session.selectModel': { schema: sessionSelectModelRequestSchema, invoke: (api, r) => api.sessions.selectModel(r) },
|
||||
'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) },
|
||||
'goal.get': { schema: goalGetRequestSchema, invoke: (api, r) => api.goals.get(r) },
|
||||
'host.pickDirectory': { schema: hostPickDirectoryRequestSchema, invoke: (api, r, signal) => api.host.pickDirectory(r, signal) },
|
||||
'host.openPath': { schema: hostOpenPathRequestSchema, invoke: (api, r, signal) => api.host.openPath(r, signal) },
|
||||
'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.delete': { schema: workspaceDeleteRequestSchema, invoke: (api, r) => api.workspace.delete(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) },
|
||||
'goal.create': { schema: goalCreateRequestSchema, invoke: (api, r) => api.goals.create(r) },
|
||||
'goal.edit': { schema: goalEditRequestSchema, invoke: (api, r) => api.goals.edit(r) },
|
||||
'goal.pause': { schema: goalPauseRequestSchema, invoke: (api, r) => api.goals.pause(r) },
|
||||
@@ -95,14 +120,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 })
|
||||
@@ -207,7 +234,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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', '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 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
|
||||
|
||||
@@ -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 = () => {}
|
||||
|
||||
|
||||
38
packages/host/apiproxy/src/native-command.ts
Normal file
38
packages/host/apiproxy/src/native-command.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/** Shared no-shell `execFile` runner for native host dialogs and openers. */
|
||||
|
||||
import { execFile } from 'node:child_process'
|
||||
|
||||
/** Testable command boundary; native implementations never invoke a shell. */
|
||||
export type NativeCommandRunner = (
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
signal: AbortSignal,
|
||||
) => Promise<{ stdout: string; stderr: string }>
|
||||
|
||||
/**
|
||||
* Run a host command with utf8 stdio, abort propagation, and Windows hide.
|
||||
* @param command - executable path or PATH name.
|
||||
* @param args - argv (never a shell string).
|
||||
* @param signal - caller/connection lifetime; abort terminates the child.
|
||||
* @returns captured stdout/stderr on exit 0.
|
||||
*/
|
||||
export const runNativeCommand: NativeCommandRunner = (command, args, signal) =>
|
||||
new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
command,
|
||||
[...args],
|
||||
{ encoding: 'utf8', signal, windowsHide: true },
|
||||
(error, stdout, stderr) => {
|
||||
if (error !== null) {
|
||||
const failure = Object.assign(new Error(error.message, { cause: error }), {
|
||||
code: error.code,
|
||||
stdout,
|
||||
stderr,
|
||||
})
|
||||
reject(failure)
|
||||
return
|
||||
}
|
||||
resolve({ stdout, stderr })
|
||||
},
|
||||
)
|
||||
})
|
||||
111
packages/host/apiproxy/src/native-directory-picker.ts
Normal file
111
packages/host/apiproxy/src/native-directory-picker.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/** Cross-platform native single-directory picker used by the local GUI carrier. */
|
||||
|
||||
import { runNativeCommand, type NativeCommandRunner } from './native-command.ts'
|
||||
|
||||
/** Testable command boundary; native implementations never invoke a shell. */
|
||||
export type DirectoryPickerRunner = NativeCommandRunner
|
||||
|
||||
/** Injectable platform facts for deterministic adapter tests. */
|
||||
export interface DirectoryPickerInternals {
|
||||
platform?: NodeJS.Platform
|
||||
run?: DirectoryPickerRunner
|
||||
}
|
||||
|
||||
function outputPath(stdout: string): string | null {
|
||||
const path = stdout.replace(/[\r\n]+$/, '')
|
||||
return path === '' ? null : path
|
||||
}
|
||||
|
||||
function errorCode(error: unknown): string | number | undefined {
|
||||
if (typeof error !== 'object' || error === null || !('code' in error)) return undefined
|
||||
const code = (error as { code?: unknown }).code
|
||||
return typeof code === 'string' || typeof code === 'number' ? code : undefined
|
||||
}
|
||||
|
||||
function errorStderr(error: unknown): string {
|
||||
if (typeof error !== 'object' || error === null || !('stderr' in error)) return ''
|
||||
const stderr = (error as { stderr?: unknown }).stderr
|
||||
return typeof stderr === 'string' ? stderr : ''
|
||||
}
|
||||
|
||||
function isMissingCommand(error: unknown): boolean {
|
||||
return errorCode(error) === 'ENOENT'
|
||||
}
|
||||
|
||||
function rethrowIfAborted(signal: AbortSignal, error: unknown): void {
|
||||
if (signal.aborted) throw error
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the platform directory picker.
|
||||
* @param signal - caller/connection lifetime; abort terminates the native command.
|
||||
* @param internals - platform and runner seam for deterministic tests.
|
||||
* @returns the selected path, or null when the user cancels.
|
||||
*/
|
||||
export async function pickNativeDirectory(
|
||||
signal: AbortSignal,
|
||||
internals: DirectoryPickerInternals = {},
|
||||
): Promise<string | null> {
|
||||
const platform = internals.platform ?? process.platform
|
||||
const run = internals.run ?? runNativeCommand
|
||||
|
||||
if (platform === 'darwin') {
|
||||
try {
|
||||
const result = await run('osascript', [
|
||||
'-e', 'set selectedFolder to choose folder with prompt "Select Workspace Directory"',
|
||||
'-e', 'POSIX path of selectedFolder',
|
||||
], signal)
|
||||
return outputPath(result.stdout)
|
||||
} catch (error: unknown) {
|
||||
if (!signal.aborted && errorCode(error) === 1
|
||||
&& /(?:User canceled|-128)/i.test(errorStderr(error))) return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
const script = [
|
||||
"$ErrorActionPreference = 'Stop'",
|
||||
'Add-Type -AssemblyName System.Windows.Forms',
|
||||
'$dialog = New-Object System.Windows.Forms.FolderBrowserDialog',
|
||||
"$dialog.Description = 'Select Workspace Directory'",
|
||||
'$dialog.ShowNewFolderButton = $true',
|
||||
'$result = $dialog.ShowDialog()',
|
||||
'if ($result -eq [System.Windows.Forms.DialogResult]::OK) {',
|
||||
' [Console]::OutputEncoding = [System.Text.Encoding]::UTF8',
|
||||
' [Console]::WriteLine($dialog.SelectedPath)',
|
||||
'}',
|
||||
].join('; ')
|
||||
const result = await run('powershell.exe', ['-NoProfile', '-STA', '-Command', script], signal)
|
||||
return outputPath(result.stdout)
|
||||
}
|
||||
|
||||
if (platform === 'linux') {
|
||||
try {
|
||||
const result = await run('zenity', [
|
||||
'--file-selection', '--directory', '--title=Select Workspace Directory',
|
||||
], signal)
|
||||
return outputPath(result.stdout)
|
||||
} catch (error: unknown) {
|
||||
rethrowIfAborted(signal, error)
|
||||
if (errorCode(error) === 1) return null
|
||||
if (!isMissingCommand(error)) throw error
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await run('kdialog', [
|
||||
'--getexistingdirectory', '.', '--title', 'Select Workspace Directory',
|
||||
], signal)
|
||||
return outputPath(result.stdout)
|
||||
} catch (error: unknown) {
|
||||
rethrowIfAborted(signal, error)
|
||||
if (errorCode(error) === 1) return null
|
||||
if (isMissingCommand(error)) {
|
||||
throw new Error('no supported native directory picker found (install zenity or kdialog)')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`native directory picker is unsupported on ${platform}`)
|
||||
}
|
||||
53
packages/host/apiproxy/src/native-path-opener.ts
Normal file
53
packages/host/apiproxy/src/native-path-opener.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/** Cross-platform open-with-default-application used by the local GUI carrier. */
|
||||
|
||||
import { runNativeCommand, type NativeCommandRunner } from './native-command.ts'
|
||||
|
||||
/** Testable command boundary; native implementations never invoke a shell. */
|
||||
export type PathOpenerRunner = NativeCommandRunner
|
||||
|
||||
/** Injectable platform facts for deterministic adapter tests. */
|
||||
export interface PathOpenerInternals {
|
||||
platform?: NodeJS.Platform
|
||||
run?: PathOpenerRunner
|
||||
}
|
||||
|
||||
/** PowerShell single-quoted literal (doubles embedded quotes). */
|
||||
function powershellLiteral(path: string): string {
|
||||
return `'${path.replace(/'/g, "''")}'`
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a filesystem path with the operating system's default application.
|
||||
* @param path - absolute or host-resolvable path (caller owns resolution).
|
||||
* @param signal - caller/connection lifetime; abort terminates the native command.
|
||||
* @param internals - platform and runner seam for deterministic tests.
|
||||
*/
|
||||
export async function openNativePath(
|
||||
path: string,
|
||||
signal: AbortSignal,
|
||||
internals: PathOpenerInternals = {},
|
||||
): Promise<void> {
|
||||
const platform = internals.platform ?? process.platform
|
||||
const run = internals.run ?? runNativeCommand
|
||||
|
||||
if (platform === 'darwin') {
|
||||
await run('open', [path], signal)
|
||||
return
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
await run('powershell.exe', [
|
||||
'-NoProfile',
|
||||
'-Command',
|
||||
`Invoke-Item -LiteralPath ${powershellLiteral(path)}`,
|
||||
], signal)
|
||||
return
|
||||
}
|
||||
|
||||
if (platform === 'linux') {
|
||||
await run('xdg-open', [path], signal)
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error(`native path opener is unsupported on ${platform}`)
|
||||
}
|
||||
Reference in New Issue
Block a user