Bladeren bron

refactor(agent-presets): expose browser operations through Remote

imccyu 3 weken geleden
bovenliggende
commit
306419cc84

+ 4 - 175
packages/host/apiproxy/src/api-proxy.ts

@@ -8,11 +8,11 @@ import { dirname } from 'node:path'
 import type { Context } from '@deepseek-ai/cordis'
 import type { Context } from '@deepseek-ai/cordis'
 import type { ModelSelection } from '@deepseek-ai/dsh-agent'
 import type { ModelSelection } from '@deepseek-ai/dsh-agent'
 import type {} from '@deepseek-ai/dsh-agent-presets/types'
 import type {} from '@deepseek-ai/dsh-agent-presets/types'
-import type { Session, SessionId } from '@deepseek-ai/dsh-session'
+import type { SessionId } from '@deepseek-ai/dsh-session'
 import { SubagentError } from '@deepseek-ai/dsh-subagent'
 import { SubagentError } from '@deepseek-ai/dsh-subagent'
 import { isUserInvocable } from '@deepseek-ai/dsh-skill'
 import { isUserInvocable } from '@deepseek-ai/dsh-skill'
 import {
 import {
-  InvalidPresetIdError, PresetExistsError, PresetMountError,
+  InvalidPresetIdError, PresetExistsError,
   PresetNotWritableError, UnknownPresetError,
   PresetNotWritableError, UnknownPresetError,
 } from '@deepseek-ai/dsh-agent-presets'
 } from '@deepseek-ai/dsh-agent-presets'
 import type {
 import type {
@@ -80,42 +80,6 @@ function err<T>(request: RpcRequest<unknown>, error: RpcError): RpcResponse<T> {
   return { rpcId: request.rpcId, result: { ok: false, error } }
   return { rpcId: request.rpcId, result: { ok: false, error } }
 }
 }
 
 
-/**
- * Map an agent-preset selection failure onto its stable RPC refusal, or leave
- * unrelated failures to the caller.
- * @param request - the request being answered.
- * @param error - the thrown value.
- * @returns the refusal, or undefined when the caller should keep handling.
- */
-function presetFailure(request: RpcRequest<unknown>, error: unknown): RpcResponse<never> | undefined {
-  if (error instanceof UnknownPresetError) {
-    return err(request, {
-      code: 'agent-preset-not-found',
-      message: error.message,
-      details: { agentPreset: error.presetId, available: [...error.available] },
-    })
-  }
-  if (error instanceof PresetMountError) {
-    return err(request, {
-      code: 'agent-preset-invalid',
-      message: error.message,
-      details: { agentPreset: error.presetId, reason: error.reason },
-    })
-  }
-  return undefined
-}
-
-/**
- * Whether the session's conversation has started: no turn has run yet (a
- * turn is one model-loop execution). Standalone plugin events — command
- * lifecycle records, plan/mode, titles, goals — never open a turn, so
- * running `/plan` or `/goal` on a fresh session keeps it blank
- * (list-hidden, reusable).
- */
-function sessionBlank(session: Session): boolean {
-  return !session.events.some(event => event.type === 'turn/start')
-}
-
 /** Map a browse-primitive failure onto the wire error vocabulary (unknown throws stay internal). */
 /** Map a browse-primitive failure onto the wire error vocabulary (unknown throws stay internal). */
 function directoryError(error: unknown): RpcError {
 function directoryError(error: unknown): RpcError {
   if (error instanceof DirectoryPickerError) {
   if (error instanceof DirectoryPickerError) {
@@ -195,14 +159,6 @@ function projectionsUnavailableError(): RpcError {
   }
   }
 }
 }
 
 
-/**
- * The requested preset differs from the one this session already runs.
- *
- * A session's composition is fixed at creation: its history was produced under
- * that preset's tools, so adopting the identity under a different one would
- * replay tool calls the rebuilt agent cannot make. Naming a different preset
- * is therefore a caller error rather than a switch.
- */
 /** The roster is absent: this deployment composes no agent presets at all. */
 /** The roster is absent: this deployment composes no agent presets at all. */
 function noRoster(agentPreset: string): RpcError {
 function noRoster(agentPreset: string): RpcError {
   return {
   return {
@@ -239,17 +195,6 @@ function presetError(agentPreset: string, error: unknown): RpcError {
 export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy {
 export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy {
   const sessionExportCompressionLevel = defaults.sessionExportCompressionLevel
   const sessionExportCompressionLevel = defaults.sessionExportCompressionLevel
     ?? DEFAULT_SESSION_LOG_COMPRESSION_LEVEL
     ?? DEFAULT_SESSION_LOG_COMPRESSION_LEVEL
-  /**
-   * Serializes `agentPreset.select` per session. Two concurrent selects both
-   * pass the blank check, and the second `unmountPresetFor` then finds nothing
-   * to unmount because the first already removed the record — leaving two
-   * compositions registered into one agent layer. The client's `busy` flag is
-   * not enforcement: the wire is reachable directly.
-   */
-  const presetSwitches = new Map<SessionId, Promise<unknown>>()
-  const agentFor = (sessionId: SessionId) =>
-    ctx.sessionController.resolveAgent(sessionId)
-
   /** Resolve a Session's live or standing preset scope without resuming it. */
   /** Resolve a Session's live or standing preset scope without resuming it. */
   async function sessionScopeFor(
   async function sessionScopeFor(
     sessionId: SessionId,
     sessionId: SessionId,
@@ -580,112 +525,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
     },
     },
 
 
     agentPresets: {
     agentPresets: {
-      // A deployment with no roster answers with an empty list rather than an
-      // error: composing no presets is a valid deployment, and the browser
-      // simply offers no choice.
-      async list(request) {
-        const presets = ctx.get('agentPresets')
-        if (presets === undefined) return ok(request, { presets: [], authorable: false, hasDocument: false })
-        const defaultId = presets.defaultId
-        return ok(request, {
-          presets: (await presets.list()).map(preset => ({
-            id: preset.id,
-            trust: preset.trust,
-            isDefault: preset.id === defaultId,
-            ...preset.name === undefined ? {} : { name: preset.name },
-            ...preset.description === undefined ? {} : { description: preset.description },
-            ...preset.broken === undefined ? {} : { broken: preset.broken },
-          })),
-          authorable: presets.authorable,
-          hasDocument: canOpenPaths(),
-        })
-      },
-
-      // Recomposing is limited to a blank session because a started
-      // conversation's history was produced under its preset's tools; the
-      // agent and the session survive, only the composition is swapped.
-      async select(request) {
-        const { sessionId, agentPreset } = request.payload
-        const presets = ctx.get('agentPresets')
-        if (presets === undefined) {
-          return err(request, {
-            code: 'agent-preset-not-found',
-            message: 'this deployment composes no agent presets',
-            details: { agentPreset, available: [] },
-          })
-        }
-        const found = await agentFor(sessionId)
-        if ('error' in found) return err(request, found.error)
-        const { agent } = found
-        const swap = async (): Promise<RpcResponse<{ agentPreset: string }>> => {
-          // Re-read inside the queue: an earlier switch may have run, and a
-          // conversation may have started, since this request arrived.
-          if (!sessionBlank(agent.session)) {
-            return err(request, {
-              code: 'agent-preset-locked',
-              message: `session "${sessionId}" has already started; its agent preset is fixed`,
-              details: { sessionId, agentPreset },
-            })
-          }
-          try {
-            const preset = await presets.recompose(agent.ctx, agentPreset)
-            // Recorded only after the swap committed: the log states what the
-            // agent runs, and a rejected mount leaves the previous composition.
-            agent.session.append('agent-preset/selected', { agentPreset: preset.id })
-            return ok(request, { agentPreset: preset.id })
-          } catch (error: unknown) {
-            const refused = presetFailure(request, error)
-            if (refused !== undefined) return refused
-            return err(request, {
-              code: 'internal',
-              message: `failed to select agent preset "${agentPreset}": ${String(error)}`,
-              details: {},
-            })
-          }
-        }
-        const queued = presetSwitches.get(sessionId) ?? Promise.resolve()
-        const turn = queued.then(swap)
-        presetSwitches.set(sessionId, turn.catch(() => undefined))
-        try {
-          return await turn
-        } finally {
-          if (presetSwitches.get(sessionId) === turn) presetSwitches.delete(sessionId)
-        }
-      },
-
-      // A composition names the plugins a session runs, so reading one is
-      // reconnaissance, and copy/remove/openDocument manage the roster and
-      // drive the host desktop. Connection authenticates the complete API.
-      async read(request) {
-        const { agentPreset } = request.payload
-        const presets = ctx.get('agentPresets')
-        if (presets === undefined) return err(request, noRoster(agentPreset))
-        try {
-          const preset = await presets.resolve(agentPreset)
-          return ok(request, {
-            agentPreset: preset.id,
-            trust: preset.trust,
-            content: await presets.read(preset.id),
-            ...preset.name === undefined ? {} : { name: preset.name },
-            ...preset.description === undefined ? {} : { description: preset.description },
-          })
-        } catch (error: unknown) {
-          return err(request, presetError(agentPreset, error))
-        }
-      },
-
-      async copy(request) {
-        const { from, agentPreset, name } = request.payload
-        const presets = ctx.get('agentPresets')
-        if (presets === undefined) return err(request, noRoster(agentPreset))
-        try {
-          await presets.copy(from, agentPreset, name)
-          return ok(request, { agentPreset })
-        } catch (error: unknown) {
-          return err(request, presetError(agentPreset, error))
-        }
-      },
-
+      // Only the desktop opener remains here: the roster, selection, and
+      // authoring calls are the AgentPresets service's own Remote namespace.
       async openDocument(request, signal) {
       async openDocument(request, signal) {
         const { agentPreset } = request.payload
         const { agentPreset } = request.payload
         const presets = ctx.get('agentPresets')
         const presets = ctx.get('agentPresets')
@@ -708,18 +549,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
           return err(request, presetError(agentPreset, error))
           return err(request, presetError(agentPreset, error))
         }
         }
       },
       },
-
-      async remove(request) {
-        const { agentPreset } = request.payload
-        const presets = ctx.get('agentPresets')
-        if (presets === undefined) return err(request, noRoster(agentPreset))
-        try {
-          await presets.remove(agentPreset)
-          return ok(request, {})
-        } catch (error: unknown) {
-          return err(request, presetError(agentPreset, error))
-        }
-      },
     },
     },
 
 
     skills: {
     skills: {

+ 1 - 70
packages/host/apiproxy/src/api/agent-presets.schema.ts

@@ -1,71 +1,11 @@
 /**
 /**
  * agent-presets domain zod schemas (names derived from map keys:
  * agent-presets domain zod schemas (names derived from map keys:
- * agentPresetListRequestSchema / agentPresetListValueSchema).
+ * agentPresetOpenDocumentRequestSchema / agentPresetOpenDocumentValueSchema).
  */
  */
 
 
 import { z } from 'zod'
 import { z } from 'zod'
 import type { RequestPayload, ResponseValue } from './rpc-map.ts'
 import type { RequestPayload, ResponseValue } from './rpc-map.ts'
 import type { Wire } from './rpc.schema.ts'
 import type { Wire } from './rpc.schema.ts'
-import { sessionIdSchema } from './ids.schema.ts'
-import type { AgentPresetEntry } from './agent-presets.ts'
-
-/** AgentPresetEntry row of agentPreset.list. */
-export const agentPresetEntrySchema = z.object({
-  id: z.string().min(1),
-  trust: z.union([z.literal('system'), z.literal('user')]),
-  isDefault: z.boolean(),
-  name: z.string().optional(),
-  description: z.string().optional(),
-  broken: z.string().min(1).optional(),
-}) satisfies z.ZodType<Wire<AgentPresetEntry>>
-
-/** agentPreset.list request payload. */
-export const agentPresetListRequestSchema = z.object({
-}) satisfies z.ZodType<Wire<RequestPayload<'agentPreset.list'>>>
-
-/** agentPreset.list response value. */
-export const agentPresetListValueSchema = z.object({
-  presets: z.array(agentPresetEntrySchema),
-  authorable: z.boolean(),
-  hasDocument: z.boolean(),
-}) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.list'>>>
-
-/** agentPreset.select request payload. */
-export const agentPresetSelectRequestSchema = z.object({
-  sessionId: sessionIdSchema,
-  agentPreset: z.string().min(1),
-}) satisfies z.ZodType<Wire<RequestPayload<'agentPreset.select'>>>
-
-/** agentPreset.select response value. */
-export const agentPresetSelectValueSchema = z.object({
-  agentPreset: z.string(),
-}) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.select'>>>
-
-/** agentPreset.read request payload. */
-export const agentPresetReadRequestSchema = z.object({
-  agentPreset: z.string().min(1),
-}) satisfies z.ZodType<Wire<RequestPayload<'agentPreset.read'>>>
-
-/** agentPreset.read response value. */
-export const agentPresetReadValueSchema = z.object({
-  agentPreset: z.string(),
-  trust: z.union([z.literal('system'), z.literal('user')]),
-  content: z.string(),
-  name: z.string().optional(),
-  description: z.string().optional(),
-}) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.read'>>>
-
-/** agentPreset.copy request payload. */
-export const agentPresetCopyRequestSchema = z.object({
-  from: z.string().min(1),
-  agentPreset: z.string().min(1),
-  name: z.string().optional(),
-}) satisfies z.ZodType<Wire<RequestPayload<'agentPreset.copy'>>>
-
-/** agentPreset.copy response value. */
-export const agentPresetCopyValueSchema = z.object({
-  agentPreset: z.string(),
-}) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.copy'>>>
 
 
 /** agentPreset.openDocument request payload. */
 /** agentPreset.openDocument request payload. */
 export const agentPresetOpenDocumentRequestSchema = z.object({
 export const agentPresetOpenDocumentRequestSchema = z.object({
@@ -77,12 +17,3 @@ export const agentPresetOpenDocumentValueSchema = z.union([
   z.object({ opened: z.literal(true) }),
   z.object({ opened: z.literal(true) }),
   z.object({ opened: z.literal(false), path: z.string() }),
   z.object({ opened: z.literal(false), path: z.string() }),
 ]) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.openDocument'>>>
 ]) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.openDocument'>>>
-
-/** agentPreset.remove request payload. */
-export const agentPresetRemoveRequestSchema = z.object({
-  agentPreset: z.string().min(1),
-}) satisfies z.ZodType<Wire<RequestPayload<'agentPreset.remove'>>>
-
-/** agentPreset.remove response value. */
-export const agentPresetRemoveValueSchema = z.object({
-}) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.remove'>>>

+ 8 - 99
packages/host/apiproxy/src/api/agent-presets.ts

@@ -1,116 +1,25 @@
 /**
 /**
- * agent-presets domain contract: the roster a browser offers when starting a
- * session, plus the authoring calls behind it.
+ * agent-presets domain contract: handing one preset's directory to the
+ * platform opener, which is the only agent-preset call still carried here.
  *
  *
- * A composition names the plugins a session runs, so reading one is
- * reconnaissance; although authoring is copy-only (no caller supplies
- * composition text or a path), copying and deleting still rearrange what the
- * deployment offers. Connection authenticates these calls with the complete
- * Host API rather than assigning a separate method tier.
+ * The roster and its authoring calls are the AgentPresets service's own Remote
+ * namespace. This one stays because the opener is a Host desktop integration
+ * rather than a preset operation.
  */
  */
 
 
-import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { RpcRequest, RpcResponse } from './rpc.ts'
 import type { RpcRequest, RpcResponse } from './rpc.ts'
 
 
-/** One preset the deployment can compose a session's agent from. */
-export interface AgentPresetEntry {
-  /** Stable identifier, also the display name until presets carry metadata. */
-  readonly id: string
-  /**
-   * Whether the preset ships with the deployment or was authored locally.
-   * A `user` preset is exactly as privileged as the plugins it names, so a
-   * surface offering one should say so rather than present it as vetted.
-   */
-  readonly trust: 'system' | 'user'
-  /** Whether a session that names no preset gets this one. */
-  readonly isDefault: boolean
-  /**
-   * Display name the preset published, absent when it published none. A
-   * surface falls back to {@link id}; it is never a second identity, and it
-   * never decides trust — a locally authored preset cannot name itself into
-   * the shipped set.
-   */
-  readonly name?: string
-  /** One sentence on what the preset is for, when it published one. */
-  readonly description?: string
-  /**
-   * Why this preset cannot compose a session, absent when it can. A broken
-   * preset stays listed — its directory still occupies the id, so a surface
-   * must be able to show and delete it — but offering it for selection would
-   * only defer this reason to a failed session start.
-   */
-  readonly broken?: string
-}
-
 /** agent-preset-domain unary methods (the map key agentPreset.* of RpcMethodMap). */
 /** agent-preset-domain unary methods (the map key agentPreset.* of RpcMethodMap). */
 export interface AgentPresetsApi {
 export interface AgentPresetsApi {
-  /**
-   * Lists every preset the deployment currently supplies, in root-precedence
-   * order — the roots as configured, each root's own presets sorted by id,
-   * and the first root to supply an id wins. The order is not globally
-   * sorted: a user root's preset sits in that root's block, not among the
-   * shipped ids.
-   * An empty roster means the deployment composes no presets at all, and
-   * every session shares the host composition. `authorable` reports whether
-   * the deployment configures a root new presets can be written to, and
-   * `hasDocument` whether `openDocument` can hand a preset directory to a
-   * native opener — both deployment facts rather than per-preset ones, and
-   * neither exposes a Host path.
-   */
-  list(request: RpcRequest<{}>):
-  Promise<RpcResponse<{ presets: readonly AgentPresetEntry[]; authorable: boolean; hasDocument: boolean }>>
-
-  /**
-   * Recompose one session's agent from a different preset.
-   *
-   * Allowed only while the session is blank — no turn has run. Once a
-   * conversation starts, its history was produced under that preset's tools,
-   * and swapping them would leave logged tool calls the new composition cannot
-   * make; the attempt answers `agent-preset-locked`.
-   */
-  select(request: RpcRequest<{ sessionId: SessionId; agentPreset: string }>):
-  Promise<RpcResponse<{ agentPreset: string }>>
-
-  /**
-   * Read one preset's composition text, for the read-only viewer.
-   *
-   * Privileged: a composition names the plugins a session runs, so reading
-   * one is reconnaissance.
-   */
-  read(request: RpcRequest<{ agentPreset: string }>):
-  Promise<RpcResponse<{
-    agentPreset: string
-    trust: 'system' | 'user'
-    content: string
-    name?: string
-    description?: string
-  }>>
-
-  /**
-   * Create a locally authored preset by copying an existing one whole.
-   *
-   * The only authoring write. No composition text and no path crosses the
-   * wire: `from` and `agentPreset` are ids the Host resolves against its own
-   * roots, so a copy is exactly as loadable as its source and grants nothing
-   * the roster did not already carry. The copy keeps the source's description
-   * (the file is the author's to edit afterwards) but not its name — `name`
-   * here or the id fallback is what distinguishes the rows.
-   */
-  copy(request: RpcRequest<{ from: string; agentPreset: string; name?: string }>):
-  Promise<RpcResponse<{ agentPreset: string }>>
-
   /**
   /**
    * Hand one locally authored preset's DIRECTORY to the platform opener, for
    * Hand one locally authored preset's DIRECTORY to the platform opener, for
    * editing the files, which are the only composition editor. The request
    * editing the files, which are the only composition editor. The request
    * carries an id, never a path — the Host resolves it — so no browser
    * carries an id, never a path — the Host resolves it — so no browser
    * payload can select an arbitrary filesystem target. Where the deployment
    * payload can select an arbitrary filesystem target. Where the deployment
-   * has no native opener (`hasDocument: false` on `list`), the reply carries
-   * the resolved directory for the surface to show as text instead. Shipped
-   * presets are refused: their install is not the user's to manage.
+   * has no native opener (`canOpenPath: false` on `host.describe`), the reply
+   * carries the resolved directory for the surface to show as text instead.
+   * Shipped presets are refused: their install is not the user's to manage.
    */
    */
   openDocument(request: RpcRequest<{ agentPreset: string }>, signal: AbortSignal):
   openDocument(request: RpcRequest<{ agentPreset: string }>, signal: AbortSignal):
   Promise<RpcResponse<{ opened: true } | { opened: false; path: string }>>
   Promise<RpcResponse<{ opened: true } | { opened: false; path: string }>>
-
-  /** Delete a locally authored preset. Shipped presets are refused. */
-  remove(request: RpcRequest<{ agentPreset: string }>): Promise<RpcResponse<{}>>
 }
 }

+ 1 - 1
packages/host/apiproxy/src/api/index.ts

@@ -37,7 +37,7 @@ export type {
   SubagentPromptReceipt, SubagentsApi,
   SubagentPromptReceipt, SubagentsApi,
 } from './subagents.ts'
 } from './subagents.ts'
 export type { SkillsApi, SkillEntry } from './skills.ts'
 export type { SkillsApi, SkillEntry } from './skills.ts'
-export type { AgentPresetsApi, AgentPresetEntry } from './agent-presets.ts'
+export type { AgentPresetsApi } from './agent-presets.ts'
 export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView } from './settings.ts'
 export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView } from './settings.ts'
 export type { CredentialsApi, CredentialView } from './credentials.ts'
 export type { CredentialsApi, CredentialView } from './credentials.ts'
 export type { ConfigurableProviderView, DiscoveredModelView, LlmApi } from './llm.ts'
 export type { ConfigurableProviderView, DiscoveredModelView, LlmApi } from './llm.ts'

+ 0 - 5
packages/host/apiproxy/src/api/rpc-map.ts

@@ -27,12 +27,7 @@ export interface RpcMethodMap {
   'host.createDirectory': HostApi['createDirectory']
   'host.createDirectory': HostApi['createDirectory']
   'host.openPath': HostApi['openPath']
   'host.openPath': HostApi['openPath']
   'skill.list': SkillsApi['list']
   'skill.list': SkillsApi['list']
-  'agentPreset.list': AgentPresetsApi['list']
-  'agentPreset.select': AgentPresetsApi['select']
-  'agentPreset.read': AgentPresetsApi['read']
-  'agentPreset.copy': AgentPresetsApi['copy']
   'agentPreset.openDocument': AgentPresetsApi['openDocument']
   'agentPreset.openDocument': AgentPresetsApi['openDocument']
-  'agentPreset.remove': AgentPresetsApi['remove']
   'settings.describe': SettingsApi['describe']
   'settings.describe': SettingsApi['describe']
   'settings.openDocument': SettingsApi['openDocument']
   'settings.openDocument': SettingsApi['openDocument']
   'settings.update': SettingsApi['update']
   'settings.update': SettingsApi['update']

+ 1 - 17
packages/host/apiproxy/src/fetch/client.ts

@@ -18,8 +18,7 @@ import {
 } from '../api/host.schema.ts'
 } from '../api/host.schema.ts'
 import { skillListValueSchema } from '../api/skills.schema.ts'
 import { skillListValueSchema } from '../api/skills.schema.ts'
 import {
 import {
-  agentPresetCopyValueSchema, agentPresetListValueSchema, agentPresetOpenDocumentValueSchema,
-  agentPresetReadValueSchema, agentPresetRemoveValueSchema, agentPresetSelectValueSchema,
+  agentPresetOpenDocumentValueSchema,
 } from '../api/agent-presets.schema.ts'
 } from '../api/agent-presets.schema.ts'
 import {
 import {
   settingsDescribeValueSchema, settingsMutateValueSchema, settingsOpenDocumentValueSchema,
   settingsDescribeValueSchema, settingsMutateValueSchema, settingsOpenDocumentValueSchema,
@@ -64,12 +63,7 @@ export interface IApiClient {
     list(payload: RequestPayload<'skill.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'skill.list'>>>
     list(payload: RequestPayload<'skill.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'skill.list'>>>
   }
   }
   agentPresets: {
   agentPresets: {
-    list(payload: RequestPayload<'agentPreset.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.list'>>>
-    select(payload: RequestPayload<'agentPreset.select'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.select'>>>
-    read(payload: RequestPayload<'agentPreset.read'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.read'>>>
-    copy(payload: RequestPayload<'agentPreset.copy'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.copy'>>>
     openDocument(payload: RequestPayload<'agentPreset.openDocument'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.openDocument'>>>
     openDocument(payload: RequestPayload<'agentPreset.openDocument'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.openDocument'>>>
-    remove(payload: RequestPayload<'agentPreset.remove'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.remove'>>>
   }
   }
   settings: {
   settings: {
     describe(payload: RequestPayload<'settings.describe'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'settings.describe'>>>
     describe(payload: RequestPayload<'settings.describe'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'settings.describe'>>>
@@ -104,12 +98,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
   'host.createDirectory': hostCreateDirectoryValueSchema,
   'host.createDirectory': hostCreateDirectoryValueSchema,
   'host.openPath': hostOpenPathValueSchema,
   'host.openPath': hostOpenPathValueSchema,
   'skill.list': skillListValueSchema,
   'skill.list': skillListValueSchema,
-  'agentPreset.list': agentPresetListValueSchema,
-  'agentPreset.select': agentPresetSelectValueSchema,
-  'agentPreset.read': agentPresetReadValueSchema,
-  'agentPreset.copy': agentPresetCopyValueSchema,
   'agentPreset.openDocument': agentPresetOpenDocumentValueSchema,
   'agentPreset.openDocument': agentPresetOpenDocumentValueSchema,
-  'agentPreset.remove': agentPresetRemoveValueSchema,
   'settings.describe': settingsDescribeValueSchema,
   'settings.describe': settingsDescribeValueSchema,
   'settings.openDocument': settingsOpenDocumentValueSchema,
   'settings.openDocument': settingsOpenDocumentValueSchema,
   'settings.update': settingsUpdateValueSchema,
   'settings.update': settingsUpdateValueSchema,
@@ -279,12 +268,7 @@ export abstract class AbstractApiClient implements IApiClient {
   // the whole gateway, and with it the host `Context` merges, into every
   // the whole gateway, and with it the host `Context` merges, into every
   // Client program that imports this carrier.
   // Client program that imports this carrier.
   readonly agentPresets: IApiClient['agentPresets'] = {
   readonly agentPresets: IApiClient['agentPresets'] = {
-    list: (payload, signal) => this.callUnary('agentPreset.list', payload, signal),
-    select: (payload, signal) => this.callUnary('agentPreset.select', payload, signal),
-    read: (payload, signal) => this.callUnary('agentPreset.read', payload, signal),
-    copy: (payload, signal) => this.callUnary('agentPreset.copy', payload, signal),
     openDocument: (payload, signal) => this.callUnary('agentPreset.openDocument', payload, signal),
     openDocument: (payload, signal) => this.callUnary('agentPreset.openDocument', payload, signal),
-    remove: (payload, signal) => this.callUnary('agentPreset.remove', payload, signal),
   }
   }
 
 
   readonly settings: IApiClient['settings'] = {
   readonly settings: IApiClient['settings'] = {

+ 1 - 7
packages/host/apiproxy/src/fetch/handler.ts

@@ -21,8 +21,7 @@ import {
 } from '../api/host.schema.ts'
 } from '../api/host.schema.ts'
 import { skillListRequestSchema } from '../api/skills.schema.ts'
 import { skillListRequestSchema } from '../api/skills.schema.ts'
 import {
 import {
-  agentPresetCopyRequestSchema, agentPresetListRequestSchema, agentPresetOpenDocumentRequestSchema,
-  agentPresetReadRequestSchema, agentPresetRemoveRequestSchema, agentPresetSelectRequestSchema,
+  agentPresetOpenDocumentRequestSchema,
 } from '../api/agent-presets.schema.ts'
 } from '../api/agent-presets.schema.ts'
 import {
 import {
   settingsDescribeRequestSchema, settingsMutateRequestSchema, settingsOpenDocumentRequestSchema,
   settingsDescribeRequestSchema, settingsMutateRequestSchema, settingsOpenDocumentRequestSchema,
@@ -64,12 +63,7 @@ const UNARY_ROUTES: UnaryRoutes = {
   'host.createDirectory': { schema: hostCreateDirectoryRequestSchema, invoke: (api, r) => api.host.createDirectory(r) },
   'host.createDirectory': { schema: hostCreateDirectoryRequestSchema, invoke: (api, r) => api.host.createDirectory(r) },
   'host.openPath': { schema: hostOpenPathRequestSchema, invoke: (api, r, signal) => api.host.openPath(r, signal) },
   'host.openPath': { schema: hostOpenPathRequestSchema, invoke: (api, r, signal) => api.host.openPath(r, signal) },
   'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) },
   'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) },
-  'agentPreset.list': { schema: agentPresetListRequestSchema, invoke: (api, r) => api.agentPresets.list(r) },
-  'agentPreset.select': { schema: agentPresetSelectRequestSchema, invoke: (api, r) => api.agentPresets.select(r) },
-  'agentPreset.read': { schema: agentPresetReadRequestSchema, invoke: (api, r) => api.agentPresets.read(r) },
-  'agentPreset.copy': { schema: agentPresetCopyRequestSchema, invoke: (api, r) => api.agentPresets.copy(r) },
   'agentPreset.openDocument': { schema: agentPresetOpenDocumentRequestSchema, invoke: (api, r, signal) => api.agentPresets.openDocument(r, signal) },
   'agentPreset.openDocument': { schema: agentPresetOpenDocumentRequestSchema, invoke: (api, r, signal) => api.agentPresets.openDocument(r, signal) },
-  'agentPreset.remove': { schema: agentPresetRemoveRequestSchema, invoke: (api, r) => api.agentPresets.remove(r) },
   'settings.describe': { schema: settingsDescribeRequestSchema, invoke: (api, r) => api.settings.describe(r) },
   'settings.describe': { schema: settingsDescribeRequestSchema, invoke: (api, r) => api.settings.describe(r) },
   'settings.openDocument': { schema: settingsOpenDocumentRequestSchema, invoke: (api, r, signal) => api.settings.openDocument(r, signal) },
   'settings.openDocument': { schema: settingsOpenDocumentRequestSchema, invoke: (api, r, signal) => api.settings.openDocument(r, signal) },
   'settings.update': { schema: settingsUpdateRequestSchema, invoke: (api, r) => api.settings.update(r) },
   'settings.update': { schema: settingsUpdateRequestSchema, invoke: (api, r) => api.settings.update(r) },

+ 15 - 1
packages/preset/agent-presets/package.json

@@ -26,6 +26,14 @@
       "types": "./lib/types/types.d.ts",
       "types": "./lib/types/types.d.ts",
       "default": "./lib/types/types.js"
       "default": "./lib/types/types.js"
     },
     },
+    "./typert": {
+      "types": "./lib/typert.host.d.ts",
+      "default": "./lib/typert.host.js"
+    },
+    "./remote": {
+      "types": "./lib/typert.remote-client.d.ts",
+      "default": "./lib/typert.remote-client.js"
+    },
     "./src/*": "./src/*",
     "./src/*": "./src/*",
     "./package.json": "./package.json"
     "./package.json": "./package.json"
   },
   },
@@ -34,7 +42,11 @@
     "lib/invariant.js",
     "lib/invariant.js",
     "presets",
     "presets",
     "lib/types/**/*.js",
     "lib/types/**/*.js",
-    "lib/types/**/*.d.ts"
+    "lib/types/**/*.d.ts",
+    "lib/typert.host.js",
+    "lib/typert.host.d.ts",
+    "lib/typert.remote-client.js",
+    "lib/typert.remote-client.d.ts"
   ],
   ],
   "license": "MIT",
   "license": "MIT",
   "peerDependencies": {
   "peerDependencies": {
@@ -50,6 +62,7 @@
     "@deepseek-ai/dsh-settings": "workspace:^",
     "@deepseek-ai/dsh-settings": "workspace:^",
     "@deepseek-ai/dsh-system-prompt": "workspace:^",
     "@deepseek-ai/dsh-system-prompt": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
+    "@deepseek-ai/dsh-typert-protocol": "workspace:^",
     "@deepseek-ai/cordis": "workspace:^"
     "@deepseek-ai/cordis": "workspace:^"
   },
   },
   "dependencies": {
   "dependencies": {
@@ -73,6 +86,7 @@
     "@deepseek-ai/dsh-settings-file": "workspace:^",
     "@deepseek-ai/dsh-settings-file": "workspace:^",
     "@deepseek-ai/dsh-system-prompt": "workspace:^",
     "@deepseek-ai/dsh-system-prompt": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
+    "@deepseek-ai/dsh-typert-protocol": "workspace:^",
     "@deepseek-ai/cordis": "workspace:^"
     "@deepseek-ai/cordis": "workspace:^"
   }
   }
 }
 }

+ 213 - 6
packages/preset/agent-presets/src/index.ts

@@ -22,27 +22,96 @@
  */
  */
 
 
 import { stat } from 'node:fs/promises'
 import { stat } from 'node:fs/promises'
-import { Context, Service } from '@deepseek-ai/cordis'
+import { Context } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
 import z from '@deepseek-ai/schemastery'
+import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
 import { bindScopeParent, createScope, scopeOf, type Scope, type ScopeKey, type ScopeParentBinding } from '@deepseek-ai/dsh-scope'
 import { bindScopeParent, createScope, scopeOf, type Scope, type ScopeKey, type ScopeParentBinding } from '@deepseek-ai/dsh-scope'
 // Type-only: resolves the `agent/created` lifecycle event this service watches.
 // Type-only: resolves the `agent/created` lifecycle event this service watches.
 import type {} from '@deepseek-ai/dsh-agent'
 import type {} from '@deepseek-ai/dsh-agent'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import type { AgentPresetDocument, AgentPresetErrorDetailsMap, AgentPresetRoster } from './types.ts'
 import type {} from '@deepseek-ai/dsh-session-projection'
 import type {} from '@deepseek-ai/dsh-session-projection'
 // Type-only: resolves the registry notification emitted after scope reparenting.
 // Type-only: resolves the registry notification emitted after scope reparenting.
 import type {} from '@deepseek-ai/dsh-tools'
 import type {} from '@deepseek-ai/dsh-tools'
 import { settingsNamespace, type SettingsScope, type default as SettingsService } from '@deepseek-ai/dsh-settings'
 import { settingsNamespace, type SettingsScope, type default as SettingsService } from '@deepseek-ai/dsh-settings'
 import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
 import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
 import { discoverPresets, SHIPPED_PRESET_ROOT, USER_PRESET_DIR } from './discovery.ts'
 import { discoverPresets, SHIPPED_PRESET_ROOT, USER_PRESET_DIR } from './discovery.ts'
-import { copyComposition, deleteComposition, readComposition } from './authoring.ts'
+import {
+  copyComposition, deleteComposition, readComposition,
+  InvalidPresetIdError, PresetExistsError, PresetNotWritableError,
+} from './authoring.ts'
 import { mountPreset, serviceForAgent, standingMountFor } from './mount.ts'
 import { mountPreset, serviceForAgent, standingMountFor } from './mount.ts'
-import { PresetExistsError } from './authoring.ts'
-import { PresetMountError, UnknownPresetError, type AgentPreset, type Config, type PresetRoot } from './preset.ts'
+import {
+  PresetLockedError, PresetMountError, UnknownPresetError,
+  type AgentPreset, type Config, type PresetRoot,
+} from './preset.ts'
 import { agentPresetProjectionDefinition } from './session.ts'
 import { agentPresetProjectionDefinition } from './session.ts'
 export type * from './types.ts'
 export type * from './types.ts'
 
 
 /** Settings namespace carrying the user's chosen default preset. */
 /** Settings namespace carrying the user's chosen default preset. */
 export const SETTINGS_NAMESPACE = 'agent-presets'
 export const SETTINGS_NAMESPACE = 'agent-presets'
 
 
+/** Construct one typed preset failure for the Remote carrier. */
+function remotePresetFailure<Code extends keyof AgentPresetErrorDetailsMap>(
+  code: Code,
+  message: string,
+  details: AgentPresetErrorDetailsMap[Code],
+): TypertRemoteFailure {
+  return new TypertRemoteFailure({ code, message, details })
+}
+
+/** Map one preset rejection to its stable Remote code and details. */
+function presetFailure(error: unknown, agentPreset: string): TypertRemoteFailure | undefined {
+  if (error instanceof UnknownPresetError) {
+    return remotePresetFailure(
+      'agent-preset-not-found',
+      error.message,
+      { agentPreset: error.presetId, available: [...error.available] },
+    )
+  }
+  if (error instanceof PresetMountError) {
+    return remotePresetFailure(
+      'agent-preset-invalid',
+      error.message,
+      { agentPreset: error.presetId, reason: error.reason },
+    )
+  }
+  if (error instanceof InvalidPresetIdError || error instanceof PresetExistsError) {
+    return remotePresetFailure(
+      'agent-preset-invalid',
+      error.message,
+      { agentPreset: error.presetId, reason: error.message },
+    )
+  }
+  if (error instanceof PresetNotWritableError) {
+    return remotePresetFailure(
+      'agent-preset-read-only',
+      error.message,
+      { agentPreset, reason: error.message },
+    )
+  }
+  if (error instanceof PresetLockedError) {
+    return remotePresetFailure(
+      'agent-preset-locked',
+      `session "${error.sessionId}" has already started; its agent preset is fixed`,
+      { sessionId: error.sessionId, agentPreset: error.presetId },
+    )
+  }
+  return undefined
+}
+
+/** Refuse an empty preset id before invoking a domain operation. */
+function validatePresetId(value: string, field: 'agentPreset' | 'from'): void {
+  if (value.length === 0) {
+    throw remotePresetFailure('bad-request', `${field} must be a non-empty string`, {})
+  }
+}
+
+/** Throw the stable preset failure or the caller's operation-specific fallback. */
+function rejectPreset(error: unknown, agentPreset: string, fallbackMessage: string): never {
+  throw presetFailure(error, agentPreset) ?? remotePresetFailure('internal', fallbackMessage, {})
+}
+
 /** The user-writable slice of this plugin's config. */
 /** The user-writable slice of this plugin's config. */
 export interface AgentPresetSettings {
 export interface AgentPresetSettings {
   /** Preset mounted when a session names none. */
   /** Preset mounted when a session names none. */
@@ -67,7 +136,7 @@ export {
   PresetNotWritableError, readComposition, writableRoot,
   PresetNotWritableError, readComposition, writableRoot,
 } from './authoring.ts'
 } from './authoring.ts'
 export { agentPresetProjectionDefinition } from './session.ts'
 export { agentPresetProjectionDefinition } from './session.ts'
-export { PresetMountError, UnknownPresetError } from './preset.ts'
+export { PresetLockedError, PresetMountError, UnknownPresetError } from './preset.ts'
 export type { AgentPreset, Config, PresetRoot, PresetTrust } from './preset.ts'
 export type { AgentPreset, Config, PresetRoot, PresetTrust } from './preset.ts'
 
 
 declare module '@deepseek-ai/cordis' {
 declare module '@deepseek-ai/cordis' {
@@ -83,7 +152,7 @@ declare module '@deepseek-ai/cordis' {
  * call so a preset authored while the process runs is visible immediately,
  * call so a preset authored while the process runs is visible immediately,
  * and a preset deleted underneath a picker disappears from the next read.
  * and a preset deleted underneath a picker disappears from the next read.
  */
  */
-export class AgentPresets extends Service {
+export class AgentPresets extends TypertRemoteService {
   static inject = ['loader']
   static inject = ['loader']
 
 
   /** Runtime schema for the preset roster. */
   /** Runtime schema for the preset roster. */
@@ -213,6 +282,30 @@ export class AgentPresets extends Service {
     return await discoverPresets(this.resolvedRoots)
     return await discoverPresets(this.resolvedRoots)
   }
   }
 
 
+  /**
+   * The roster off the Host: {@link list} projected to path-free rows, with
+   * the default marked and this deployment's authoring capability beside it.
+   *
+   * Whether a client can open a preset's directory is the Host's own opener
+   * capability, not a roster property — a caller needing both joins them.
+   * @returns the rows and the authoring capability.
+   */
+  @Remote('list')
+  async remoteExportList(): Promise<AgentPresetRoster> {
+    const defaultId = this.defaultId
+    return {
+      presets: (await this.list()).map(preset => ({
+        id: preset.id,
+        trust: preset.trust,
+        isDefault: preset.id === defaultId,
+        ...preset.name === undefined ? {} : { name: preset.name },
+        ...preset.description === undefined ? {} : { description: preset.description },
+        ...preset.broken === undefined ? {} : { broken: preset.broken },
+      })),
+      authorable: this.authorable,
+    }
+  }
+
   /**
   /**
    * Resolve one preset by id.
    * Resolve one preset by id.
    *
    *
@@ -376,6 +469,30 @@ export class AgentPresets extends Service {
     return await readComposition(await this.resolve(id))
     return await readComposition(await this.resolve(id))
   }
   }
 
 
+  /**
+   * One preset's composition text with the roster row it belongs to.
+   * @param agentPreset - the preset id.
+   * @returns the composition beside its trust and published metadata.
+   * @throws {TypertRemoteFailure} `bad-request` for an empty id, or
+   * `agent-preset-not-found` when no configured root supplies it.
+   */
+  @Remote('read')
+  async readDocument(agentPreset: string): Promise<AgentPresetDocument> {
+    validatePresetId(agentPreset, 'agentPreset')
+    try {
+      const preset = await this.resolve(agentPreset)
+      return {
+        agentPreset: preset.id,
+        trust: preset.trust,
+        content: await this.read(preset.id),
+        ...preset.name === undefined ? {} : { name: preset.name },
+        ...preset.description === undefined ? {} : { description: preset.description },
+      }
+    } catch (error: unknown) {
+      rejectPreset(error, agentPreset, `agent preset "${agentPreset}": ${String(error)}`)
+    }
+  }
+
   /**
   /**
    * Create a locally authored preset by copying an existing one whole.
    * Create a locally authored preset by copying an existing one whole.
    *
    *
@@ -406,8 +523,29 @@ export class AgentPresets extends Service {
     this.standing.delete(id)
     this.standing.delete(id)
   }
   }
 
 
+  /**
+   * Copy one preset through the Remote API.
+   * @param from - the source preset id.
+   * @param id - the new preset id.
+   * @param name - the copy's optional display name.
+   * @returns once the copy is stored.
+   * @throws {TypertRemoteFailure} with the corresponding stable preset code
+   * and details when the copy is refused.
+   */
+  @Remote('copy')
+  async remoteExportCopy(from: string, id: string, name?: string): Promise<void> {
+    validatePresetId(from, 'from')
+    validatePresetId(id, 'agentPreset')
+    try {
+      await this.copy(from, id, name)
+    } catch (error: unknown) {
+      rejectPreset(error, id, `agent preset "${id}": ${String(error)}`)
+    }
+  }
+
   /**
   /**
    * Delete a locally authored preset.
    * Delete a locally authored preset.
+   *
    * @param id - the preset id.
    * @param id - the preset id.
    * @throws when the preset is unknown or ships with the deployment.
    * @throws when the preset is unknown or ships with the deployment.
    */
    */
@@ -429,6 +567,23 @@ export class AgentPresets extends Service {
     )
     )
   }
   }
 
 
+  /**
+   * Delete one preset through the Remote API.
+   * @param id - the preset id.
+   * @returns once the preset is deleted.
+   * @throws {TypertRemoteFailure} with the corresponding stable preset code
+   * and details when deletion is refused.
+   */
+  @Remote('deletePreset')
+  async remoteExportDelete(id: string): Promise<void> {
+    validatePresetId(id, 'agentPreset')
+    try {
+      await this.remove(id)
+    } catch (error: unknown) {
+      rejectPreset(error, id, `agent preset "${id}": ${String(error)}`)
+    }
+  }
+
   /**
   /**
    * One agent's instance of a service its preset mounted.
    * One agent's instance of a service its preset mounted.
    *
    *
@@ -495,6 +650,58 @@ export class AgentPresets extends Service {
     return preset
     return preset
   }
   }
 
 
+  /**
+   * Serializes {@link select} per session. Two concurrent selects would both
+   * pass the blank check, and the second re-link would then find the record
+   * the first already replaced — leaving two compositions registered into one
+   * agent layer. A client's `busy` flag is not enforcement: the wire is
+   * reachable directly.
+   *
+   * Entries hold a failure-swallowing guard rather than the turn itself, so a
+   * refused switch does not reject the next caller's chain.
+   */
+  private readonly switches = new Map<string, Promise<unknown>>()
+
+  /**
+   * Compose a blank session's agent from a different preset and record it.
+   * @param agent - the session's live agent, resolved from the wire identity.
+   * @param agentPreset - the preset to compose the agent from instead.
+   * @returns the preset id that was recorded.
+   * @throws {TypertRemoteFailure} with `bad-request`, `agent-preset-locked`,
+   * `agent-preset-not-found`, or `agent-preset-invalid` when refused.
+   */
+  @Remote('select')
+  async select(agent: Agent, agentPreset: string): Promise<string> {
+    validatePresetId(agentPreset, 'agentPreset')
+    const queued = this.switches.get(agent.id) ?? Promise.resolve()
+    const turn = queued.then(() => this.swap(agent, agentPreset))
+    const guard = turn.catch(() => undefined)
+    this.switches.set(agent.id, guard)
+    try {
+      return await turn
+    } catch (error: unknown) {
+      return rejectPreset(error, agentPreset, `failed to select agent preset "${agentPreset}": ${String(error)}`)
+    } finally {
+      if (this.switches.get(agent.id) === guard) this.switches.delete(agent.id)
+    }
+  }
+
+  /** One queued switch: re-check, recompose, then record what the agent runs. */
+  private async swap(agent: Agent, agentPreset: string): Promise<string> {
+    // Re-read inside the queue: an earlier switch may have run, and a
+    // conversation may have started, since this call was queued. A turn is one
+    // model-loop execution; standalone plugin events never open one, so a
+    // session that has only run commands is still blank.
+    if (agent.session.events.some(event => event.type === 'turn/start')) {
+      throw new PresetLockedError(agent.id, agentPreset)
+    }
+    const preset = await this.recompose(agent.ctx, agentPreset)
+    // Recorded only after the swap committed: the log states what the agent
+    // runs, and a rejected mount leaves the previous composition.
+    agent.session.append('agent-preset/selected', { agentPreset: preset.id })
+    return preset.id
+  }
+
   /**
   /**
    * The standing scope key of one preset, for a host reader with no agent.
    * The standing scope key of one preset, for a host reader with no agent.
    *
    *

+ 18 - 0
packages/preset/agent-presets/src/preset.ts

@@ -1,5 +1,7 @@
 /** Agent-preset vocabulary shared by discovery, mounting, and consumers. */
 /** Agent-preset vocabulary shared by discovery, mounting, and consumers. */
 
 
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+
 /**
 /**
  * Where a preset's composition came from. A `system` preset ships with the
  * Where a preset's composition came from. A `system` preset ships with the
  * deployment; a `user` preset was authored locally, by a person or by an
  * deployment; a `user` preset was authored locally, by a person or by an
@@ -87,6 +89,22 @@ export class UnknownPresetError extends Error {
   }
   }
 }
 }
 
 
+/**
+ * The session's composition is fixed: its conversation has started, so its
+ * history was produced under the preset it runs and swapping the composition
+ * would leave logged tool calls the new one cannot make.
+ */
+export class PresetLockedError extends Error {
+  constructor(
+    /** The session whose composition is already fixed. */
+    readonly sessionId: SessionId,
+    /** The preset that was refused. */
+    readonly presetId: string,
+  ) {
+    super(`agent-presets: session "${sessionId}" has already started; its agent preset is fixed`)
+  }
+}
+
 /** A preset exists but its composition cannot be installed. */
 /** A preset exists but its composition cannot be installed. */
 export class PresetMountError extends Error {
 export class PresetMountError extends Error {
   constructor(
   constructor(

+ 70 - 1
packages/preset/agent-presets/src/types.ts

@@ -1,5 +1,74 @@
-/** Client-safe event declarations owned by the agent-preset domain. */
+/** Client-safe payloads and event declarations owned by the agent-preset domain. */
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { PresetTrust } from './preset.ts'
+
+export type { PresetTrust } from './preset.ts'
+
+/**
+ * One roster row as a client reads it. Path-free: a preset is addressed by id
+ * everywhere off the Host, and the composition's location is the Host's own.
+ */
+export interface AgentPresetRow {
+  /** Stable identifier; also the label's fallback. */
+  readonly id: string
+  /** Trust of the root this preset was discovered under. */
+  readonly trust: PresetTrust
+  /** Whether a session naming no preset composes this one. */
+  readonly isDefault: boolean
+  /** Display name the preset published. */
+  readonly name?: string
+  /** One sentence on what this preset is for. */
+  readonly description?: string
+  /** Why this preset cannot compose a session; absent when it can. */
+  readonly broken?: string
+}
+
+/** The roster one deployment currently supplies, with its authoring capability. */
+export interface AgentPresetRoster {
+  /** Every preset the configured roots supply, first-root-wins per id. */
+  readonly presets: readonly AgentPresetRow[]
+  /** Whether this deployment has a root locally authored presets go to. */
+  readonly authorable: boolean
+}
+
+/** Stable details for agent-preset failures returned by the Remote namespace. */
+export interface AgentPresetErrorDetailsMap {
+  /** A required preset id is empty. */
+  'bad-request': Record<never, never>
+  /** No configured root supplies the requested id. */
+  'agent-preset-not-found': { readonly agentPreset: string; readonly available: readonly string[] }
+  /** The id is unusable, already taken, or its composition cannot be installed. */
+  'agent-preset-invalid': { readonly agentPreset: string; readonly reason: string }
+  /** The preset ships with the deployment and is not the user's to change. */
+  'agent-preset-read-only': { readonly agentPreset: string; readonly reason: string }
+  /** The session's conversation has started, so its composition is fixed. */
+  'agent-preset-locked': { readonly sessionId: SessionId; readonly agentPreset: string }
+  /** The preset operation failed without a caller-actionable classification. */
+  internal: Record<never, never>
+}
+
+/** One agent-preset refusal as a client reads it. */
+export type AgentPresetError = {
+  [Code in keyof AgentPresetErrorDetailsMap]: {
+    readonly code: Code
+    readonly message: string
+    readonly details: AgentPresetErrorDetailsMap[Code]
+  }
+}[keyof AgentPresetErrorDetailsMap]
+
+/** One preset's composition text beside the row it belongs to. */
+export interface AgentPresetDocument {
+  /** The preset the composition belongs to. */
+  readonly agentPreset: string
+  /** Trust of the root this preset was discovered under. */
+  readonly trust: PresetTrust
+  /** The composition exactly as stored. */
+  readonly content: string
+  /** Display name the preset published. */
+  readonly name?: string
+  /** One sentence on what this preset is for. */
+  readonly description?: string
+}
 
 
 declare module '@deepseek-ai/dsh-session-projection/types' {
 declare module '@deepseek-ai/dsh-session-projection/types' {
   interface SessionProjectionStateMap {
   interface SessionProjectionStateMap {

+ 3 - 0
packages/preset/agent-presets/tsconfig.json

@@ -36,6 +36,9 @@
     {
     {
       "path": "../../core/tools"
       "path": "../../core/tools"
     },
     },
+    {
+      "path": "../../typert/protocol"
+    },
     {
     {
       "path": "../../settings/settings"
       "path": "../../settings/settings"
     },
     },

+ 3 - 0
pnpm-lock.yaml

@@ -6649,6 +6649,9 @@ importers:
       '@deepseek-ai/dsh-tools':
       '@deepseek-ai/dsh-tools':
         specifier: workspace:^
         specifier: workspace:^
         version: link:../../core/tools
         version: link:../../core/tools
+      '@deepseek-ai/dsh-typert-protocol':
+        specifier: workspace:^
+        version: link:../../typert/protocol
 
 
   packages/preset/persona:
   packages/preset/persona:
     dependencies:
     dependencies: