Browse Source

feat(subagent): migrate browser control to Remote

imccyu 1 month ago
parent
commit
377f3b4f1d

+ 2 - 0
packages/api/remotes/package.json

@@ -76,6 +76,7 @@
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-session-reference": "workspace:^",
     "@deepseek-ai/dsh-settings": "workspace:^",
+    "@deepseek-ai/dsh-subagent": "workspace:^",
     "@deepseek-ai/dsh-user-approval": "workspace:^",
     "@deepseek-ai/dsh-user-questions": "workspace:^"
   },
@@ -97,6 +98,7 @@
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-session-reference": "workspace:^",
     "@deepseek-ai/dsh-settings": "workspace:^",
+    "@deepseek-ai/dsh-subagent": "workspace:^",
     "@deepseek-ai/dsh-user-approval": "workspace:^",
     "@deepseek-ai/dsh-user-questions": "workspace:^"
   }

+ 5 - 2
packages/api/remotes/src/client/index.ts

@@ -9,6 +9,7 @@ import fileReferencesRemote from '@deepseek-ai/dsh-file-reference/remote'
 import pluginInventoryRemote from '@deepseek-ai/dsh-host-plugin-inventory/remote'
 import messageFeedbackRemote from '@deepseek-ai/dsh-message-feedback/remote'
 import sessionReferencesRemote from '@deepseek-ai/dsh-session-reference/remote'
+import subagentsRemote from '@deepseek-ai/dsh-subagent/remote'
 import sessionRemote from '@deepseek-ai/dsh-api-session-controller/remote'
 import workspaceRemote from '@deepseek-ai/dsh-api-workspace-controller/remote'
 import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
@@ -22,6 +23,8 @@ export type {} from '@deepseek-ai/dsh-goal/remote'
 export type {} from '@deepseek-ai/dsh-host-plugin-inventory/remote'
 export type {} from '@deepseek-ai/dsh-message-feedback/remote'
 export type {} from '@deepseek-ai/dsh-session-reference/remote'
+export type {} from '@deepseek-ai/dsh-subagent/remote'
+export type * from '@deepseek-ai/dsh-subagent/client'
 export type {} from '@deepseek-ai/dsh-api-session-controller/remote'
 export type * from '@deepseek-ai/dsh-api-session-controller/types'
 export type {} from '@deepseek-ai/dsh-api-workspace-controller/remote'
@@ -54,7 +57,6 @@ export type {
   MessageId, ModelCatalog, ModelCatalogFailure, ModelProviderGroup, ModelReasoningEffort, ModelSelection,
   RpcError, RpcId, RpcRequest, RpcResponse, RpcResult, SessionId,
   SettingsNamespaceView, SettingsPathOpView, SkillEntry, StreamChunk,
-  SubagentAddress, SubagentCatalog,
 } from '@deepseek-ai/dsh-client-connection/client'
 export type {} from '@deepseek-ai/dsh-api-gateway/client'
 export type {} from '@deepseek-ai/dsh-cordis-host-runner/remote'
@@ -110,6 +112,7 @@ export type ClientFailure =
   | import('@deepseek-ai/dsh-client-connection/client').RpcError
   | import('@deepseek-ai/dsh-agent-presets/types').AgentPresetError
   | import('@deepseek-ai/dsh-api-session-controller/types').SessionError
+  | import('@deepseek-ai/dsh-subagent/client').SubagentControlError
   | import('@deepseek-ai/dsh-api-workspace-controller/types').WorkspaceError
 
 /** Success or failure returned by Client operations spanning both API families. */
@@ -138,7 +141,7 @@ export async function apply(ctx: Context): Promise<() => Promise<void>> {
     for (const contribution of [
       agentPresetsRemote, commandsRemote, goalsRemote, dynamicRemote, fileReferencesRemote,
       pluginInventoryRemote, messageFeedbackRemote, sessionReferencesRemote,
-      sessionRemote, workspaceRemote,
+      subagentsRemote, sessionRemote, workspaceRemote,
     ]) {
       disposers.push(await ctx.remote.$mount(contribution))
     }

+ 3 - 0
packages/api/remotes/tsconfig.client.json

@@ -54,6 +54,9 @@
     {
       "path": "../../settings/settings"
     },
+    {
+      "path": "../../subagent/subagent"
+    },
     {
       "path": "../../interaction/user-approval"
     },

+ 0 - 1
packages/host/apiproxy/package.json

@@ -61,7 +61,6 @@
     "@deepseek-ai/dsh-session-query": "workspace:^",
     "@deepseek-ai/dsh-settings": "workspace:^",
     "@deepseek-ai/dsh-skill": "workspace:^",
-    "@deepseek-ai/dsh-subagent": "workspace:^",
     "@deepseek-ai/dsh-util-crypto": "workspace:^",
     "@deepseek-ai/schemastery": "workspace:^",
     "fflate": "^0.8.2",

+ 0 - 163
packages/host/apiproxy/src/api-proxy.ts

@@ -9,7 +9,6 @@ import type { Context } from '@deepseek-ai/cordis'
 import type { ModelSelection } from '@deepseek-ai/dsh-agent'
 import type {} from '@deepseek-ai/dsh-agent-presets/types'
 import type { SessionId } from '@deepseek-ai/dsh-session'
-import { SubagentError } from '@deepseek-ai/dsh-subagent'
 import { isUserInvocable } from '@deepseek-ai/dsh-skill'
 import {
   InvalidPresetIdError, PresetExistsError,
@@ -19,7 +18,6 @@ import type {
   ApiProxy, ConfigurableProviderView, CredentialView,
   SettingsNamespaceView,
 } from './api/index.ts'
-import type { SessionRequestId } from '@deepseek-ai/dsh-api-session-controller/types'
 import { buildModelCatalog } from '@deepseek-ai/dsh-api-session-controller'
 import { SessionQueryError } from '@deepseek-ai/dsh-session-query'
 import {
@@ -46,25 +44,6 @@ import type { RpcError, RpcRequest, RpcResponse } from './api/rpc.ts'
 import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
 import { canOpenNativePath, openNativePath, openNativeTextFile } from './native-path-opener.ts'
 
-/** Strict browser-zone profile: UTC or an IANA Area/Location-style identifier. */
-const IANA_TIME_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/
-
-/** Validate and canonicalize one browser-supplied IANA zone at the wire boundary. */
-function canonicalClientTimeZone(value: string): string | undefined {
-  if (value.length === 0 || value.trim() !== value
-    || (value !== 'UTC' && !IANA_TIME_ZONE.test(value))) return undefined
-  try {
-    const canonical = new Intl.DateTimeFormat('en-US', { timeZone: value })
-      .resolvedOptions().timeZone
-    /* v8 ignore next -- Intl returns UTC or a canonical IANA Area/Location for accepted input. */
-    if (canonical !== 'UTC' && !IANA_TIME_ZONE.test(canonical)) return undefined
-    return canonical
-  } catch {
-    // Intl rejects unsupported zone names; the RPC maps that parser rejection below.
-    return undefined
-  }
-}
-
 /** Read live abort state across awaits without treating it as synchronously immutable. */
 function isAborted(signal: AbortSignal): boolean {
   return signal.aborted
@@ -110,55 +89,6 @@ export interface ApiProxyDefaults {
   canOpenPath?: () => boolean
 }
 
-/** Map continuation admission failures without exposing provider details. */
-function subagentPromptError(
-  request: RpcRequest<{ childSessionId: SessionId }>,
-  error: unknown,
-  signal: AbortSignal,
-): RpcResponse<never> {
-  const childSessionId = request.payload.childSessionId
-  if (signal.aborted) {
-    return err(request, { code: 'cancelled', message: 'subagent prompt was cancelled', details: {} })
-  }
-  if (error instanceof SubagentError) {
-    switch (error.code) {
-      case 'NOT_RESUMABLE':
-        return err(request, {
-          code: 'subagent-not-resumable',
-          message: 'subagent cannot be resumed',
-          details: { childSessionId },
-        })
-      case 'UNAUTHORIZED':
-        return err(request, {
-          code: 'subagent-unauthorized',
-          message: 'subagent does not belong to this parent',
-          details: { childSessionId },
-        })
-      case 'DRAINING':
-      case 'ACTIVATION_CLOSING':
-      case 'CONTINUATION_UNAVAILABLE':
-      case 'PERSISTENCE_UNAVAILABLE':
-        return err(request, {
-          code: 'subagent-delivery-unavailable',
-          message: 'subagent follow-up is temporarily unavailable',
-          details: { childSessionId },
-        })
-      default:
-        break
-    }
-  }
-  return err(request, { code: 'internal', message: 'subagent prompt failed', details: {} })
-}
-
-/** Stable RPC face of the missing projections capability, shared by every catalog read path. */
-function projectionsUnavailableError(): RpcError {
-  return {
-    code: 'internal',
-    message: 'subagent catalog is unavailable: this deployment does not mount the sessionProjections registry (load @deepseek-ai/dsh-session-projection)',
-    details: {},
-  }
-}
-
 /** The roster is absent: this deployment composes no agent presets at all. */
 function noRoster(agentPreset: string): RpcError {
   return {
@@ -341,99 +271,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
   }
 
   return {
-    subagents: {
-      async list(request, signal) {
-        try {
-          const entries = await ctx.subagents.listChildren(request.payload.parentSessionId, signal)
-          return ok(request, {
-            entries: entries.map(entry => entry.kind === 'child'
-              ? {
-                ...entry,
-                activity: ctx.agents.get(entry.id)?.status === 'running' ? 'running' : 'inactive',
-              }
-              : entry),
-            parentAvailable: ctx.agents.get(request.payload.parentSessionId) !== undefined,
-          })
-        } catch (error: unknown) {
-          if (signal?.aborted || (error instanceof SubagentError && error.code === 'CANCELLED')) {
-            return err(request, {
-              code: 'cancelled',
-              message: 'subagent catalog read was cancelled',
-              details: {},
-            })
-          }
-          if (error instanceof SubagentError && error.code === 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE') {
-            return err(request, projectionsUnavailableError())
-          }
-          return err(request, {
-            code: 'internal',
-            message: 'subagent catalog read failed',
-            details: {},
-          })
-        }
-      },
-
-      async prompt(request, signal) {
-        const { parentSessionId, childSessionId, content, clientTimeZone } = request.payload
-        const canonicalTimeZone = clientTimeZone === undefined
-          ? undefined
-          : canonicalClientTimeZone(clientTimeZone)
-        if (clientTimeZone !== undefined && canonicalTimeZone === undefined) {
-          return err(request, {
-            code: 'invalid-time-zone',
-            message: 'clientTimeZone must be UTC or a valid IANA Area/Location name',
-            details: { value: clientTimeZone },
-          })
-        }
-        const parent = ctx.agents.get(parentSessionId)
-        if (parent === undefined) {
-          return err(request, {
-            code: 'subagent-parent-unavailable',
-            message: `parent session "${parentSessionId}" is not live`,
-            details: { parentSessionId },
-          })
-        }
-        try {
-          const messageId = await ctx.subagents.followup(parent, childSessionId, content, {
-            source: {
-              kind: 'user',
-              rpcId: request.rpcId as unknown as SessionRequestId,
-              ...(canonicalTimeZone === undefined ? {} : { clientTimeZone: canonicalTimeZone }),
-            },
-            signal,
-          })
-          return ok(request, { messageId })
-        } catch (error: unknown) {
-          return subagentPromptError(request, error, signal)
-        }
-      },
-
-      // Deliberately no catalog, history, persistence, or parent Agent lookup:
-      // the core primitive alone authorizes the durable address against the
-      // live Activation, which is what keeps a live child interruptible while
-      // its parent Agent is offline. Absent targets are accepted no-ops there.
-      interrupt(request) {
-        const { parentSessionId, childSessionId } = request.payload
-        try {
-          ctx.subagents.interrupt(childSessionId, { kind: 'user', parentSessionId })
-        } catch (error: unknown) {
-          if (error instanceof SubagentError && error.code === 'UNAUTHORIZED') {
-            return Promise.resolve(err(request, {
-              code: 'subagent-unauthorized',
-              message: 'subagent does not belong to this parent',
-              details: { childSessionId },
-            }))
-          }
-          return Promise.resolve(err(request, {
-            code: 'internal',
-            message: 'subagent interrupt failed',
-            details: {},
-          }))
-        }
-        return Promise.resolve(ok(request, { accepted: true as const }))
-      },
-    },
-
     host: {
       describe(request) {
         // TODO(apiproxy-version): read the version from apps/cli/package.json.

+ 0 - 6
packages/host/apiproxy/src/api/index.ts

@@ -7,7 +7,6 @@
 import type { HostApi } from './host.ts'
 import type { AgentPresetsApi } from './agent-presets.ts'
 import type { SkillsApi } from './skills.ts'
-import type { SubagentsApi } from './subagents.ts'
 import type { SettingsApi } from './settings.ts'
 import type { CredentialsApi } from './credentials.ts'
 import type { LlmApi } from './llm.ts'
@@ -15,7 +14,6 @@ import type { DownloadsApi } from './downloads.ts'
 
 /** Root interface of the unified API. New client-request domain = one new file pair + one field here + one map row. */
 export interface ApiProxy {
-  subagents: SubagentsApi
   host: HostApi
   skills: SkillsApi
   agentPresets: AgentPresetsApi
@@ -32,10 +30,6 @@ export type {
   ModelReasoningEffort, ModelSelection,
 } from '@deepseek-ai/dsh-api-session-controller/types'
 export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts'
-export type {
-  SubagentAddress, SubagentCatalog, SubagentInterruptReceipt, SubagentListEntry,
-  SubagentPromptReceipt, SubagentsApi,
-} from './subagents.ts'
 export type { SkillsApi, SkillEntry } from './skills.ts'
 export type { AgentPresetsApi } from './agent-presets.ts'
 export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView } from './settings.ts'

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

@@ -9,7 +9,6 @@ import type { SkillsApi } from './skills.ts'
 import type { SettingsApi } from './settings.ts'
 import type { CredentialsApi } from './credentials.ts'
 import type { LlmApi } from './llm.ts'
-import type { SubagentsApi } from './subagents.ts'
 import type { RpcResponse } from './rpc.ts'
 
 /**
@@ -18,9 +17,6 @@ import type { RpcResponse } from './rpc.ts'
  * request; the carrier passes its request signal, never a wire field.
  */
 export interface RpcMethodMap {
-  'subagent.list': SubagentsApi['list']
-  'subagent.prompt': SubagentsApi['prompt']
-  'subagent.interrupt': SubagentsApi['interrupt']
   'host.describe': HostApi['describe']
   'host.pickDirectory': HostApi['pickDirectory']
   'host.listDirectory': HostApi['listDirectory']

+ 0 - 10
packages/host/apiproxy/src/api/rpc.schema.ts

@@ -49,16 +49,6 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
   z.object({ code: z.literal('settings-conflict'), message: z.string(), details: z.object({ ns: z.string(), expected: z.number(), actual: z.number() }) }),
   z.object({ code: z.literal('credential-rejected'), message: z.string(), details: z.object({ ref: z.string() }) }),
   z.object({ code: z.literal('model-discovery-failed'), message: z.string(), details: z.object({ settingsNs: z.string(), baseURL: z.string().optional() }) }),
-  z.object({ code: z.literal('subagent-parent-unavailable'), message: z.string(), details: z.object({ parentSessionId: z.string() }) }),
-  z.object({ code: z.literal('subagent-not-found'), message: z.string(), details: z.object({ parentSessionId: z.string(), childSessionId: z.string() }) }),
-  z.object({ code: z.literal('subagent-catalog-diagnostic'), message: z.string(), details: z.object({
-    parentSessionId: z.string(),
-    childSessionId: z.string(),
-    reason: z.union([z.literal('corrupt'), z.literal('unsupported'), z.literal('unavailable')]),
-  }) }),
-  z.object({ code: z.literal('subagent-not-resumable'), message: z.string(), details: z.object({ childSessionId: z.string() }) }),
-  z.object({ code: z.literal('subagent-unauthorized'), message: z.string(), details: z.object({ childSessionId: z.string() }) }),
-  z.object({ code: z.literal('subagent-delivery-unavailable'), message: z.string(), details: z.object({ childSessionId: z.string() }) }),
   z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }),
 ]) as unknown as z.ZodType<RpcError>
 

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

@@ -62,16 +62,6 @@ export interface RpcErrorDetailsMap {
    * details name the endpoint asked, never the credential offered.
    */
   'model-discovery-failed': { settingsNs: string; baseURL?: string }
-  'subagent-parent-unavailable': { parentSessionId: SessionId }
-  'subagent-not-found': { parentSessionId: SessionId; childSessionId: SessionId }
-  'subagent-catalog-diagnostic': {
-    parentSessionId: SessionId
-    childSessionId: SessionId
-    reason: 'corrupt' | 'unsupported' | 'unavailable'
-  }
-  'subagent-not-resumable': { childSessionId: SessionId }
-  'subagent-unauthorized': { childSessionId: SessionId }
-  'subagent-delivery-unavailable': { childSessionId: SessionId }
   'internal': {}
 }
 

+ 0 - 74
packages/host/apiproxy/src/api/subagents.schema.ts

@@ -1,74 +0,0 @@
-/** Zod schemas for the browser-safe subagent domain. */
-
-import { z } from 'zod'
-import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
-import type { RequestPayload, ResponseValue } from './rpc-map.ts'
-import type { Wire } from './rpc.schema.ts'
-import { sessionIdSchema } from './ids.schema.ts'
-import type { SubagentListEntry } from './subagents.ts'
-
-const contentBlockSchema = z.looseObject({ type: z.string() })
-
-/** Healthy and diagnostic durable catalog rows. */
-export const subagentListEntrySchema = z.union([
-  z.object({
-    kind: z.literal('child'),
-    id: sessionIdSchema,
-    mode: z.literal('one-shot'),
-    activity: z.union([z.literal('running'), z.literal('inactive')]),
-    hasChildren: z.boolean(),
-    label: z.string().optional(),
-  }),
-  z.object({
-    kind: z.literal('child'),
-    id: sessionIdSchema,
-    mode: z.literal('continuable'),
-    activity: z.union([z.literal('running'), z.literal('inactive')]),
-    hasChildren: z.boolean(),
-    label: z.string(),
-  }),
-  z.object({
-    kind: z.literal('diagnostic'),
-    id: sessionIdSchema,
-    reason: z.union([z.literal('corrupt'), z.literal('unsupported'), z.literal('unavailable')]),
-  }),
-]) satisfies z.ZodType<Wire<SubagentListEntry>>
-
-/** subagent.list request payload. */
-export const subagentListRequestSchema = z.object({
-  parentSessionId: sessionIdSchema,
-}) satisfies z.ZodType<Wire<RequestPayload<'subagent.list'>>>
-
-/** subagent.list response value. */
-export const subagentListValueSchema = z.object({
-  entries: z.array(subagentListEntrySchema),
-  parentAvailable: z.boolean(),
-}) satisfies z.ZodType<Wire<ResponseValue<'subagent.list'>>>
-
-/** subagent.prompt request payload. */
-export const subagentPromptRequestSchema = z.object({
-  parentSessionId: sessionIdSchema,
-  childSessionId: sessionIdSchema,
-  mode: z.literal('continuable'),
-  content: z.array(contentBlockSchema),
-  clientTimeZone: z.string().optional(),
-}) as unknown as z.ZodType<RequestPayload<'subagent.prompt'>>
-
-/** subagent.interrupt request payload. */
-export const subagentInterruptRequestSchema = z.object({
-  parentSessionId: sessionIdSchema,
-  childSessionId: sessionIdSchema,
-  mode: z.literal('continuable'),
-}) satisfies z.ZodType<Wire<RequestPayload<'subagent.interrupt'>>>
-
-/** subagent.interrupt response value. */
-export const subagentInterruptValueSchema = z.object({
-  accepted: z.literal(true),
-}) satisfies z.ZodType<Wire<ResponseValue<'subagent.interrupt'>>>
-
-const messageIdSchema = z.string() as unknown as z.ZodType<MessageId>
-
-/** subagent.prompt response value. */
-export const subagentPromptValueSchema = z.object({
-  messageId: messageIdSchema,
-}) satisfies z.ZodType<Wire<ResponseValue<'subagent.prompt'>>>

+ 0 - 101
packages/host/apiproxy/src/api/subagents.ts

@@ -1,101 +0,0 @@
-/** Browser-safe subagent catalog, continuation, and interrupt contract. */
-
-import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
-import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
-import type { SessionId } from '@deepseek-ai/dsh-session/types'
-import type { RpcRequest, RpcResponse } from './rpc.ts'
-
-/** Complete durable direct-child catalog row. */
-export type SubagentListEntry =
-  | {
-    kind: 'child'
-    id: SessionId
-    /** Whether the child Agent driver is running at the Host sampling boundary. */
-    activity: 'running' | 'inactive'
-    /** Whether a direct descendant has durable `origin: 'subagent'`. */
-    hasChildren: boolean
-  } & (
-    | {
-      mode: 'one-shot'
-      label?: string
-    }
-    | {
-      mode: 'continuable'
-      label: string
-    }
-  )
-  | {
-    kind: 'diagnostic'
-    id: SessionId
-    reason: 'corrupt' | 'unsupported' | 'unavailable'
-  }
-
-/** Inbox identity returned once the continuation accepts one human message. */
-export interface SubagentPromptReceipt {
-  messageId: MessageId
-}
-
-/** Uniform acknowledgement that one interrupt request was admitted. */
-export interface SubagentInterruptReceipt {
-  accepted: true
-}
-
-/** Durable parent/child address that selects subagent transport in the client. */
-export type SubagentAddress =
-  & {
-    parentSessionId: SessionId
-    childSessionId: SessionId
-  }
-  & (
-    | { mode: 'one-shot' }
-    | { mode: 'continuable' }
-  )
-
-/** Complete direct-child catalog plus the delivery-time parent availability hint. */
-export interface SubagentCatalog {
-  entries: SubagentListEntry[]
-  parentAvailable: boolean
-}
-
-/** Subagent-domain unary methods. */
-export interface SubagentsApi {
-  /**
-   * Lists direct session-backed children without loading either side. Parent
-   * availability is a hint; continuable prompt performs the authoritative
-   * check.
-   */
-  list(
-    request: RpcRequest<{ parentSessionId: SessionId }>,
-    signal?: AbortSignal,
-  ): Promise<RpcResponse<SubagentCatalog>>
-
-  /**
-   * Delivers human content to a continuable child through the exact live
-   * parent's continuation owner. Success identifies the message accepted by
-   * the child's FIFO inbox; later execution is independent of this request.
-   * Optional browser-zone provenance is validated and logged on that message.
-   */
-  prompt(
-    request: RpcRequest<
-      Extract<SubagentAddress, { mode: 'continuable' }> & {
-        content: ContentBlock[]
-        /** Optional browser zone sampled for this exact human prompt. */
-        clientTimeZone?: string
-      }
-    >,
-    signal: AbortSignal,
-  ): Promise<RpcResponse<SubagentPromptReceipt>>
-
-  /**
-   * Interrupts a live continuable child's current turn under the address's
-   * durable direct-parent authority, without requiring a live parent Agent,
-   * consulting the catalog, or resuming anything. Fire-and-return: `accepted`
-   * acknowledges the admitted cancel signal, not target quiescence, so the
-   * child may remain visibly running briefly. Unclaimed queued follow-ups are
-   * kept and parked; an absent, idle, or already-completed target is likewise
-   * `accepted`.
-   */
-  interrupt(
-    request: RpcRequest<Extract<SubagentAddress, { mode: 'continuable' }>>,
-  ): Promise<RpcResponse<SubagentInterruptReceipt>>
-}

+ 0 - 19
packages/host/apiproxy/src/fetch/client.ts

@@ -28,11 +28,6 @@ import {
   credentialsDescribeValueSchema, credentialsSetValueSchema, credentialsUnsetValueSchema,
 } from '../api/credentials.schema.ts'
 import { llmDiscoverModelsValueSchema, llmModelsValueSchema, llmProvidersValueSchema } from '../api/llm.schema.ts'
-import {
-  subagentInterruptValueSchema,
-  subagentListValueSchema,
-  subagentPromptValueSchema,
-} from '../api/subagents.schema.ts'
 
 /**
  * Client consumption face of the contract (shape a): same domain tree as ApiProxy, but unary
@@ -47,11 +42,6 @@ import {
  * Derived per method key from RpcMethodMap so a map row addition updates this mechanically.
  */
 export interface IApiClient {
-  subagents: {
-    list(payload: RequestPayload<'subagent.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.list'>>>
-    prompt(payload: RequestPayload<'subagent.prompt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.prompt'>>>
-    interrupt(payload: RequestPayload<'subagent.interrupt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'subagent.interrupt'>>>
-  }
   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'>>>
@@ -89,9 +79,6 @@ export interface IApiClient {
  * mirror of the handler's request table; key coverage compiler-enforced against RpcMethodMap).
  */
 const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseValue<K>>> } = {
-  'subagent.list': subagentListValueSchema,
-  'subagent.prompt': subagentPromptValueSchema,
-  'subagent.interrupt': subagentInterruptValueSchema,
   'host.describe': hostDescribeValueSchema,
   'host.pickDirectory': hostPickDirectoryValueSchema,
   'host.listDirectory': hostListDirectoryValueSchema,
@@ -240,12 +227,6 @@ export abstract class AbstractApiClient implements IApiClient {
 
   // ---- IApiClient API (arrow properties so destructured/passed references stay bound) ----
 
-  readonly subagents: IApiClient['subagents'] = {
-    list: (payload, signal) => this.callUnary('subagent.list', payload, signal),
-    prompt: (payload, signal) => this.callUnary('subagent.prompt', payload, signal),
-    interrupt: (payload, signal) => this.callUnary('subagent.interrupt', 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

+ 0 - 8
packages/host/apiproxy/src/fetch/handler.ts

@@ -31,11 +31,6 @@ import {
   credentialsDescribeRequestSchema, credentialsSetRequestSchema, credentialsUnsetRequestSchema,
 } from '../api/credentials.schema.ts'
 import { llmDiscoverModelsRequestSchema, llmModelsRequestSchema, llmProvidersRequestSchema } from '../api/llm.schema.ts'
-import {
-  subagentInterruptRequestSchema,
-  subagentListRequestSchema,
-  subagentPromptRequestSchema,
-} from '../api/subagents.schema.ts'
 
 /**
  * Unary dispatch table, keyed by (and compiler-locked to) RpcMethodMap: a map row without a
@@ -54,9 +49,6 @@ type UnaryRoutes = {
 }
 
 const UNARY_ROUTES: UnaryRoutes = {
-  'subagent.list': { schema: subagentListRequestSchema, invoke: (api, r, signal) => api.subagents.list(r, signal) },
-  'subagent.prompt': { schema: subagentPromptRequestSchema, invoke: (api, r, signal) => api.subagents.prompt(r, signal) },
-  'subagent.interrupt': { schema: subagentInterruptRequestSchema, invoke: (api, r) => api.subagents.interrupt(r) },
   'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) },
   'host.pickDirectory': { schema: hostPickDirectoryRequestSchema, invoke: (api, r, signal) => api.host.pickDirectory(r, signal) },
   'host.listDirectory': { schema: hostListDirectoryRequestSchema, invoke: (api, r, signal) => api.host.listDirectory(r, signal) },

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

@@ -62,7 +62,7 @@ export interface Config {
  */
 export class ApiProxyService extends Service implements ApiProxy {
   static inject = [
-    'agentDefaultModel', 'agents', 'attachments', 'directoryPicker', 'llm', 'sessions', 'subagents', 'sessionQuery',
+    'agentDefaultModel', 'agents', 'attachments', 'directoryPicker', 'llm', 'sessions', 'sessionQuery',
     'sessionController',
   ]
 
@@ -72,7 +72,6 @@ export class ApiProxyService extends Service implements ApiProxy {
       .default(DEFAULT_SESSION_LOG_COMPRESSION_LEVEL) as z<SessionLogCompressionLevel>,
   })
 
-  readonly subagents: ApiProxy['subagents']
   readonly host: ApiProxy['host']
   readonly skills: ApiProxy['skills']
   readonly agentPresets: ApiProxy['agentPresets']
@@ -91,7 +90,6 @@ export class ApiProxyService extends Service implements ApiProxy {
         ? {}
         : { sessionExportCompressionLevel: config.sessionExportCompressionLevel }),
     })
-    this.subagents = api.subagents
     this.host = api.host
     this.skills = api.skills
     this.agentPresets = api.agentPresets

+ 0 - 3
packages/host/apiproxy/tsconfig.json

@@ -53,9 +53,6 @@
     {
       "path": "../../session-query/session-query"
     },
-    {
-      "path": "../../subagent/subagent"
-    },
     {
       "path": "../../skill/skill"
     },

+ 15 - 1
packages/subagent/subagent/package.json

@@ -26,6 +26,14 @@
       "types": "./lib/types/client.d.ts",
       "default": "./lib/types/client.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/*",
     "./package.json": "./package.json"
   },
@@ -33,7 +41,11 @@
     "lib/index.js",
     "lib/invariant.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",
   "dependencies": {
@@ -56,6 +68,7 @@
     "@deepseek-ai/dsh-session-query": "workspace:^",
     "@deepseek-ai/dsh-jobs": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
+    "@deepseek-ai/dsh-typert-protocol": "workspace:^",
     "@deepseek-ai/dsh-user-approval": "workspace:^",
     "@deepseek-ai/cordis": "workspace:^"
   },
@@ -108,6 +121,7 @@
     "@deepseek-ai/dsh-storage-json": "workspace:^",
     "@deepseek-ai/dsh-jobs": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
+    "@deepseek-ai/dsh-typert-protocol": "workspace:^",
     "@deepseek-ai/dsh-user-approval": "workspace:^",
     "@deepseek-ai/cordis": "workspace:^"
   }

+ 2 - 1
packages/subagent/subagent/src/client.ts

@@ -1,7 +1,8 @@
 /**
- * Browser-safe subagent projection vocabulary.
+ * Browser-safe subagent projection and control vocabulary.
  *
  * @module @deepseek-ai/dsh-subagent/client
  */
 
+export type * from './control-types.ts'
 export type { SubagentIdentityProjection, SubagentTimingProjection } from './projection-types.ts'

+ 147 - 0
packages/subagent/subagent/src/control-types.ts

@@ -0,0 +1,147 @@
+/**
+ * Client-safe subagent catalog and control vocabulary: the durable direct-child
+ * row both the listing and the browser catalog answer with, plus the
+ * browser-facing control surface's prompt, receipts, and failures.
+ *
+ * @module @deepseek-ai/dsh-subagent/control-types
+ */
+
+import type { Branded } from '@deepseek-ai/dsh-brand'
+import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
+import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { z as zCore } from 'zod'
+
+/**
+ * Client-minted identity of one browser prompt, persisted on the exact accepted
+ * message. It carries the Session Controller's `session-request-id` brand so a
+ * subagent prompt and an ordinary Session prompt share one identity
+ * vocabulary; that package depends on this one, so the brand is spelled here
+ * rather than imported.
+ */
+export type SubagentPromptRequestId = Branded<'session-request-id'>
+
+/**
+ * One durable direct-child row, ordered by header `createdAt` with ties broken
+ * on id. Only a candidate whose durable header has `origin: 'subagent'` is
+ * interpreted. A served `subagent` projection value produces a `child`; a
+ * settled candidate whose fold served no identity produces a `diagnostic`; a
+ * running candidate without one is omitted — its descriptor may not be
+ * appended yet (the creation window). Diagnostics relay the projection fold's
+ * outcome or a failed read, never a per-child event scan, and never expose
+ * model-hidden descriptor content.
+ */
+export type SubagentListEntry =
+  | {
+    readonly kind: 'child'
+    /** The durable child session id, stable across Activations. */
+    readonly id: SessionId
+    /**
+     * Whether the child is live at the moment its reader sampled it: the
+     * durable listing reads the Session store (`running` means the logical
+     * record is resident, `inactive` that it exists only in persistence),
+     * while the browser catalog re-samples the child's Agent driver. Neither
+     * encodes a durable outcome, and a continuable child may still reject
+     * delivery as an ownership conflict.
+     */
+    readonly activity: 'running' | 'inactive'
+    /** Whether a direct descendant has durable `origin: 'subagent'`. */
+    readonly hasChildren: boolean
+  } & (
+    | {
+      /** A terminal one-shot child. */
+      readonly mode: 'one-shot'
+      /** Optional durable creation label from the child's descriptor. */
+      readonly label?: string
+    }
+    | {
+      /** A resumable conversation. */
+      readonly mode: 'continuable'
+      /** Durable creation label from the child's descriptor. */
+      readonly label: string
+    }
+  )
+  | {
+    readonly kind: 'diagnostic'
+    /** The candidate's session id. */
+    readonly id: SessionId
+    /**
+     * Why the candidate has no `child` row: `corrupt` for a settled candidate
+     * whose projection fold served no identity (a missing, malformed, or
+     * unrecognized-version descriptor — deliberately undistinguished), and
+     * for any candidate whose log makes a registered unit's fold or schema
+     * throw (deterministic data damage, contained per child); `unavailable`
+     * when the candidate's Session observation was absent or transiently
+     * unreadable (retried on the next listing). `unsupported` is never produced; it remains in the
+     * union for consumers that route on it.
+     */
+    readonly reason: 'corrupt' | 'unsupported' | 'unavailable'
+  }
+
+/** Complete direct-child catalog plus the delivery-time parent availability hint. */
+export interface SubagentCatalog {
+  readonly entries: readonly SubagentListEntry[]
+  readonly parentAvailable: boolean
+}
+
+/** Durable parent/child address that selects subagent transport in the client. */
+export type SubagentAddress =
+  & {
+    readonly parentSessionId: SessionId
+    readonly childSessionId: SessionId
+  }
+  & (
+    | { readonly mode: 'one-shot' }
+    | { readonly mode: 'continuable' }
+  )
+
+/** One human message addressed to a continuable direct child. */
+export interface SubagentPromptRequest {
+  /** Identity persisted on the accepted message, minted before the call. */
+  readonly requestId: SubagentPromptRequestId
+  readonly parentSessionId: SessionId
+  readonly childSessionId: SessionId
+  /** Required discriminator retained from the browser control address. */
+  readonly mode: 'continuable'
+  /** Content delivered as the child's user message. */
+  readonly content: ContentBlock[]
+  /** Optional browser zone sampled for this exact human prompt. */
+  readonly clientTimeZone?: string
+}
+
+/** Inbox identity returned once the continuation accepts one human message. */
+export interface SubagentPromptReceipt {
+  readonly messageId: MessageId
+}
+
+/** Uniform acknowledgement that one interrupt request was admitted. */
+export interface SubagentInterruptReceipt {
+  readonly accepted: true
+}
+
+/**
+ * Failure details the control surface answers with. The catalog read, the
+ * prompt, and the interrupt produce these codes; a Client fabricates
+ * `subagent-not-resumable` and `subagent-delivery-unavailable` for a one-shot
+ * address it refuses before the call, so both planes read one vocabulary.
+ */
+export interface SubagentControlErrorDetailsMap {
+  'bad-request': { readonly issues: zCore.core.$ZodIssue[] }
+  cancelled: Record<never, never>
+  'invalid-time-zone': { readonly value: string }
+  'subagent-parent-unavailable': { readonly parentSessionId: SessionId }
+  'subagent-not-resumable': { readonly childSessionId: SessionId }
+  'subagent-unauthorized': { readonly childSessionId: SessionId }
+  'subagent-delivery-unavailable': { readonly childSessionId: SessionId }
+  'subagent-projections-unavailable': Record<never, never>
+  internal: Record<never, never>
+}
+
+/** One subagent control failure, returned without a carrier error. */
+export type SubagentControlError = {
+  [Code in keyof SubagentControlErrorDetailsMap]: {
+    readonly code: Code
+    readonly message: string
+    readonly details: SubagentControlErrorDetailsMap[Code]
+  }
+}[keyof SubagentControlErrorDetailsMap]

+ 180 - 0
packages/subagent/subagent/src/control.ts

@@ -0,0 +1,180 @@
+/**
+ * Browser-facing subagent control assembly: the catalog view sampled against
+ * the live Agent registry, one browser zone's validation, and the stable
+ * failure codes the Remote surface answers with.
+ *
+ * @module @deepseek-ai/dsh-subagent
+ */
+
+import type { Context } from '@deepseek-ai/cordis'
+import type { SessionId } from '@deepseek-ai/dsh-session'
+import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
+import { z } from 'zod'
+import type {
+  SubagentCatalog, SubagentControlErrorDetailsMap, SubagentListEntry,
+} from './control-types.ts'
+import { SubagentError } from './error.ts'
+
+/** Strict browser-zone profile: UTC or an IANA Area/Location-style identifier. */
+const IANA_TIME_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/
+
+const SESSION_ID_SCHEMA = z.string().min(1)
+const CONTROL_ID_SCHEMAS = {
+  'subagent.list': z.object({ parentSessionId: SESSION_ID_SCHEMA }),
+  'subagent.prompt': z.object({
+    parentSessionId: SESSION_ID_SCHEMA,
+    childSessionId: SESSION_ID_SCHEMA,
+    mode: z.literal('continuable'),
+  }),
+  'subagent.interrupt': z.object({
+    parentSessionId: SESSION_ID_SCHEMA,
+    childSessionId: SESSION_ID_SCHEMA,
+    mode: z.literal('continuable'),
+  }),
+} as const
+
+/**
+ * Validate and canonicalize one browser-supplied IANA zone at the wire boundary.
+ * @param value - the browser's reported zone name.
+ * @returns the canonical zone, or `undefined` when the name is unusable.
+ */
+export function canonicalClientTimeZone(value: string): string | undefined {
+  if (value.length === 0 || value.trim() !== value
+    || (value !== 'UTC' && !IANA_TIME_ZONE.test(value))) return undefined
+  try {
+    const canonical = new Intl.DateTimeFormat('en-US', { timeZone: value })
+      .resolvedOptions().timeZone
+    /* v8 ignore next -- Intl returns UTC or a canonical IANA Area/Location for accepted input. */
+    if (canonical !== 'UTC' && !IANA_TIME_ZONE.test(canonical)) return undefined
+    return canonical
+  } catch {
+    // Intl rejects unsupported zone names; the caller maps that parser rejection.
+    return undefined
+  }
+}
+
+/**
+ * Refuse one Remote call with a stable business failure the carrier preserves.
+ * @param code - declared caller-facing code.
+ * @param message - human-readable refusal.
+ * @param details - that code's declared detail payload.
+ * @returns Never — the failure is thrown.
+ * @throws {TypertRemoteFailure} always.
+ */
+export function rejectControl<Code extends keyof SubagentControlErrorDetailsMap>(
+  code: Code,
+  message: string,
+  details: SubagentControlErrorDetailsMap[Code],
+): never {
+  throw new TypertRemoteFailure({ code, message, details })
+}
+
+/**
+ * Apply the subagent payload checks that are stricter than generated
+ * branded-string codecs.
+ * @param method - method name carried in the failure message.
+ * @param payload - decoded control fields to validate.
+ * @throws {TypertRemoteFailure} `bad-request` with the original Zod issues.
+ */
+export function validateControlRequest(
+  method: keyof typeof CONTROL_ID_SCHEMAS,
+  payload: unknown,
+): void {
+  const parsed = CONTROL_ID_SCHEMAS[method].safeParse(payload)
+  if (!parsed.success) {
+    return rejectControl('bad-request', `invalid payload for ${method}`, {
+      issues: parsed.error.issues,
+    })
+  }
+}
+
+/**
+ * Project one durable listing onto the catalog view, replacing each row's
+ * store-derived activity with the live Agent driver's status and reporting
+ * whether the exact parent Agent is live. Without an Agent registry no driver
+ * runs at all, so every row is inactive and the parent is unavailable.
+ * @param ctx - Host context that may carry the Agent registry.
+ * @param parentSessionId - the listed parent.
+ * @param entries - the durable direct-child listing.
+ * @returns the catalog view answered to one browser.
+ */
+export function catalogView(
+  ctx: Context,
+  parentSessionId: SessionId,
+  entries: readonly SubagentListEntry[],
+): SubagentCatalog {
+  const agents = ctx.get('agents')
+  return {
+    entries: entries.map((entry): SubagentListEntry => entry.kind === 'child'
+      ? { ...entry, activity: agents?.get(entry.id)?.status === 'running' ? 'running' : 'inactive' }
+      : entry),
+    parentAvailable: agents?.get(parentSessionId) !== undefined,
+  }
+}
+
+/**
+ * Refuse one catalog read while preserving cancellation and a missing
+ * projections registry as distinct failures.
+ * @param error - the thrown value.
+ * @param signal - the caller's cancellation.
+ * @returns Never — the refusal is thrown.
+ * @throws {TypertRemoteFailure} always.
+ */
+export function rejectCatalogRead(error: unknown, signal: AbortSignal): never {
+  if (isCancellation(error, signal)) {
+    return rejectControl('cancelled', 'subagent catalog read was cancelled', {})
+  }
+  if (error instanceof SubagentError && error.code === 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE') {
+    return rejectControl(
+      'subagent-projections-unavailable',
+      'subagent catalog is unavailable: this deployment does not mount the sessionProjections registry (load @deepseek-ai/dsh-session-projection)',
+      {},
+    )
+  }
+  return rejectControl('internal', 'subagent catalog read failed', {})
+}
+
+/**
+ * Refuse one continuation prompt without exposing provider detail: admission
+ * failures the caller can act on keep their own code, everything else is
+ * internal.
+ * @param error - the thrown value.
+ * @param childSessionId - the addressed child.
+ * @param signal - the caller's cancellation.
+ * @returns Never — the refusal is thrown.
+ * @throws {TypertRemoteFailure} always.
+ */
+export function rejectPrompt(error: unknown, childSessionId: SessionId, signal: AbortSignal): never {
+  if (isCancellation(error, signal)) {
+    return rejectControl('cancelled', 'subagent prompt was cancelled', {})
+  }
+  if (error instanceof SubagentError) {
+    switch (error.code) {
+      case 'NOT_RESUMABLE':
+        return rejectControl('subagent-not-resumable', 'subagent cannot be resumed', { childSessionId })
+      case 'UNAUTHORIZED':
+        return rejectControl(
+          'subagent-unauthorized',
+          'subagent does not belong to this parent',
+          { childSessionId },
+        )
+      case 'DRAINING':
+      case 'ACTIVATION_CLOSING':
+      case 'CONTINUATION_UNAVAILABLE':
+      case 'PERSISTENCE_UNAVAILABLE':
+        return rejectControl(
+          'subagent-delivery-unavailable',
+          'subagent follow-up is temporarily unavailable',
+          { childSessionId },
+        )
+      // A code outside the admission vocabulary is not the caller's move to make.
+      default:
+        break
+    }
+  }
+  return rejectControl('internal', 'subagent prompt failed', {})
+}
+
+function isCancellation(error: unknown, signal: AbortSignal): boolean {
+  return signal.aborted || (error instanceof SubagentError && error.code === 'CANCELLED')
+}

+ 137 - 3
packages/subagent/subagent/src/index.ts

@@ -29,13 +29,25 @@
  * @module @deepseek-ai/dsh-subagent
  */
 
-import { Context, Service } from '@deepseek-ai/cordis'
+import { Context } from '@deepseek-ai/cordis'
 import { scopeTarget } from '@deepseek-ai/dsh-scope'
 import type { Scoped } from '@deepseek-ai/dsh-scope'
 import { assertObjectJsonSchema } from '@deepseek-ai/dsh-tools'
 import type { ContentBlock, MessageId } from '@deepseek-ai/dsh-llm'
 import type { Agent } from '@deepseek-ai/dsh-agent'
 import type { SessionId } from '@deepseek-ai/dsh-session'
+import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
+import {
+  canonicalClientTimeZone, catalogView, rejectCatalogRead, rejectControl, rejectPrompt,
+  validateControlRequest,
+} from './control.ts'
+import type {
+  SubagentCatalog,
+  SubagentInterruptReceipt,
+  SubagentPromptReceipt,
+  SubagentPromptRequest,
+  SubagentPromptRequestId,
+} from './control-types.ts'
 import type {
   ContinuableCreateRequest,
   ContinuableCreateSpec,
@@ -121,7 +133,8 @@ export type {
   SubagentSettledMessageSource,
 } from './continuation.ts'
 export type { ContinuableSetupContribution } from './activation-setup-registry.ts'
-export type { SubagentDescendantListEntry, SubagentListEntry } from './list-children.ts'
+export type * from './control-types.ts'
+export type { SubagentDescendantListEntry } from './list-children.ts'
 export type { SubagentRunEndInfo, SubagentRunInfo } from './types.ts'
 export type { SubagentIdentityProjection, SubagentTimingProjection } from './projection-types.ts'
 
@@ -166,8 +179,21 @@ declare module '@deepseek-ai/cordis' {
   }
 }
 
+/**
+ * Durable attribution of one browser-authored follow-up. The Session
+ * Controller declares this `user-rpc` message source and depends on this
+ * package, so the fields are spelled here: `MessageSource`'s `user` member
+ * accepts the record and the correlation id rides the durable message the
+ * Client reconciles its optimistic prompt against.
+ */
+interface BrowserPromptSource {
+  readonly kind: 'user'
+  readonly rpcId: SubagentPromptRequestId
+  readonly clientTimeZone?: string
+}
+
 /** Named provider registry with one-shot runs, durable discovery, and continuable-child operations. */
-export class SubagentRuntime extends Service {
+export class SubagentRuntime extends TypertRemoteService {
   private providers = new Map<string, SubagentProvider>()
   private continuations: SubagentContinuationManager | undefined
   /** Deployment contributions composed into unpublished continuable children. */
@@ -363,6 +389,114 @@ export class SubagentRuntime extends Service {
     return listSubagentDescendants(this.ctx, rootSessionId, signal)
   }
 
+  /**
+   * Remote face of {@link listChildren} for one browser: the durable listing
+   * plus live Agent activity and the delivery-time parent availability hint.
+   * Parent availability is a hint; {@link prompt} performs the authoritative
+   * check. Named apart from the provider-name {@link list}, which owns the
+   * member.
+   * @param parentSessionId - parent session whose direct children are listed.
+   * @param signal - carrier cancellation forwarded to Session queries.
+   * @returns the catalog view for that parent.
+   * @throws {TypertRemoteFailure} `bad-request` for an empty parent id,
+   *   `cancelled` for an aborted read, `subagent-projections-unavailable` when
+   *   the deployment has no projection registry, otherwise `internal`.
+   */
+  @Remote('list')
+  async remoteExportList(parentSessionId: SessionId, signal: AbortSignal): Promise<SubagentCatalog> {
+    validateControlRequest('subagent.list', { parentSessionId })
+    try {
+      return catalogView(this.ctx, parentSessionId, await this.listChildren(parentSessionId, signal))
+    } catch (error: unknown) {
+      return rejectCatalogRead(error, signal)
+    }
+  }
+
+  /**
+   * Deliver one browser-authored message to a continuable child through the
+   * exact live direct parent, retaining the caller-minted request identity and
+   * validated browser zone on the accepted message. Success identifies the
+   * message the child's FIFO inbox accepted; later execution is independent of
+   * this call.
+   * @param request - durable address, minted identity, content, and optional browser zone.
+   * @param signal - carrier cancellation, owning the call until inbox acceptance.
+   * @returns the accepted message's inbox identity.
+   * @throws {TypertRemoteFailure} `bad-request`, `invalid-time-zone`,
+   *   `subagent-parent-unavailable`, `subagent-not-resumable`,
+   *   `subagent-unauthorized`, `subagent-delivery-unavailable`, `cancelled`, or
+   *   `internal`.
+   */
+  @Remote('prompt')
+  async prompt(request: SubagentPromptRequest, signal: AbortSignal): Promise<SubagentPromptReceipt> {
+    const { parentSessionId, childSessionId, clientTimeZone } = request
+    validateControlRequest('subagent.prompt', request)
+    const canonicalTimeZone = clientTimeZone === undefined
+      ? undefined
+      : canonicalClientTimeZone(clientTimeZone)
+    if (clientTimeZone !== undefined && canonicalTimeZone === undefined) {
+      return rejectControl(
+        'invalid-time-zone',
+        'clientTimeZone must be UTC or a valid IANA Area/Location name',
+        { value: clientTimeZone },
+      )
+    }
+    const parent = this.ctx.get('agents')?.get(parentSessionId)
+    if (parent === undefined) {
+      return rejectControl(
+        'subagent-parent-unavailable',
+        `parent session "${parentSessionId}" is not live`,
+        { parentSessionId },
+      )
+    }
+    const source: BrowserPromptSource = {
+      kind: 'user',
+      rpcId: request.requestId,
+      ...(canonicalTimeZone === undefined ? {} : { clientTimeZone: canonicalTimeZone }),
+    }
+    const content: ContentBlock[] = [...request.content]
+    try {
+      return { messageId: await this.followup(parent, childSessionId, content, { source, signal }) }
+    } catch (error: unknown) {
+      return rejectPrompt(error, childSessionId, signal)
+    }
+  }
+
+  /**
+   * Remote face of {@link interrupt} under one durable parent address. No
+   * catalog, history, persistence, or parent Agent lookup runs: the core
+   * primitive alone authorizes the address against the live Activation, which
+   * is what keeps a live child interruptible while its parent Agent is offline.
+   * Absent, idle, and already-completed targets are accepted no-ops there.
+   * @param childSessionId - durable child session id to interrupt.
+   * @param parentSessionId - durable direct parent whose authority is claimed.
+   * @param mode - required continuable-address discriminator.
+   * @returns acknowledgement that the cancel signal was admitted, not that the target is quiescent.
+   * @throws {TypertRemoteFailure} `bad-request` for an empty id,
+   *   `subagent-unauthorized` when the address does not own the live target,
+   *   otherwise `internal`.
+   */
+  @Remote('interruptByParent')
+  interruptByParent(
+    childSessionId: SessionId,
+    parentSessionId: SessionId,
+    mode: 'continuable',
+  ): SubagentInterruptReceipt {
+    validateControlRequest('subagent.interrupt', { childSessionId, parentSessionId, mode })
+    try {
+      this.interrupt(childSessionId, { kind: 'user', parentSessionId })
+    } catch (error: unknown) {
+      if (error instanceof SubagentError && error.code === 'UNAUTHORIZED') {
+        return rejectControl(
+          'subagent-unauthorized',
+          'subagent does not belong to this parent',
+          { childSessionId },
+        )
+      }
+      return rejectControl('internal', 'subagent interrupt failed', {})
+    }
+    return { accepted: true }
+  }
+
   /**
    * Register a provider under its name. Registration is effect-scoped and HMR
    * safe; removing a provider blocks new starts but does not revoke runs that

+ 3 - 55
packages/subagent/subagent/src/list-children.ts

@@ -21,9 +21,12 @@ import type { Session, SessionHeader, SessionId } from '@deepseek-ai/dsh-session
 import type { SessionProjectionRegistry } from '@deepseek-ai/dsh-session-projection'
 import type { SessionProjectionCache } from '@deepseek-ai/dsh-session-projection-cache'
 import type { SessionObservation, SessionQueryEngine } from '@deepseek-ai/dsh-session-query'
+import type { SubagentListEntry } from './control-types.ts'
 import { SubagentError } from './error.ts'
 import type { SubagentIdentityProjection } from './projection-types.ts'
 
+export type { SubagentListEntry } from './control-types.ts'
+
 /**
  * Concurrent cold observations per explicit catalog listing. Current Session
  * persistence providers are local; a networked provider must promote this to
@@ -31,61 +34,6 @@ import type { SubagentIdentityProjection } from './projection-types.ts'
  */
 const COLD_READ_CONCURRENCY = 4
 
-/**
- * One entry of a {@link listChildren} result, ordered by header `createdAt`
- * with ties broken on id. Only a candidate whose durable header has
- * `origin: 'subagent'` is interpreted. A served `subagent` projection value
- * produces a `child`; a settled candidate whose fold served no identity
- * produces a `diagnostic`; a running candidate without one is omitted — its
- * descriptor may not be appended yet (the creation window). Diagnostics
- * relay the projection fold's outcome or a failed read, never a per-child
- * event scan, and never expose model-hidden descriptor content.
- */
-export type SubagentListEntry =
-  | {
-    readonly kind: 'child'
-    /** The durable child session id, stable across Activations. */
-    readonly id: SessionId
-    /**
-     * Store snapshot activity: `running` means the logical record is live in
-     * `ctx.sessions`; `inactive` means it exists only in persistence. Neither
-     * encodes a durable outcome, and a continuable child may still reject
-     * delivery as an ownership conflict.
-     */
-    readonly activity: 'running' | 'inactive'
-    /** Whether a direct descendant has durable `origin: 'subagent'`. */
-    readonly hasChildren: boolean
-  } & (
-    | {
-      /** A terminal one-shot child. */
-      readonly mode: 'one-shot'
-      /** Optional durable creation label from the child's descriptor. */
-      readonly label?: string
-    }
-    | {
-      /** A resumable conversation. */
-      readonly mode: 'continuable'
-      /** Durable creation label from the child's descriptor. */
-      readonly label: string
-    }
-  )
-  | {
-    readonly kind: 'diagnostic'
-    /** The candidate's session id. */
-    readonly id: SessionId
-    /**
-     * Why the candidate has no `child` row: `corrupt` for a settled candidate
-     * whose projection fold served no identity (a missing, malformed, or
-     * unrecognized-version descriptor — deliberately undistinguished), and
-     * for any candidate whose log makes a registered unit's fold or schema
-     * throw (deterministic data damage, contained per child); `unavailable`
-     * when the candidate's Session observation was absent or transiently
-     * unreadable (retried on the next listing). `unsupported` is never produced; it remains in the
-     * union for consumers that route on it.
-     */
-    readonly reason: 'corrupt' | 'unsupported' | 'unavailable'
-  }
-
 /**
  * One entry of a descendant listing: the interpreted subagent facts plus its
  * position in the complete session tree. `parentId` is the durable direct

+ 3 - 0
packages/subagent/subagent/tsconfig.json

@@ -56,6 +56,9 @@
     {
       "path": "../../jobs/jobs"
     },
+    {
+      "path": "../../typert/protocol"
+    },
     {
       "path": "../../runtime-diagnostics/invariants"
     }

+ 9 - 3
pnpm-lock.yaml

@@ -722,6 +722,9 @@ importers:
       '@deepseek-ai/dsh-settings':
         specifier: workspace:^
         version: link:../../settings/settings
+      '@deepseek-ai/dsh-subagent':
+        specifier: workspace:^
+        version: link:../../subagent/subagent
       '@deepseek-ai/dsh-user-approval':
         specifier: workspace:^
         version: link:../../interaction/user-approval
@@ -5715,9 +5718,6 @@ importers:
       '@deepseek-ai/dsh-skill':
         specifier: workspace:^
         version: link:../../skill/skill
-      '@deepseek-ai/dsh-subagent':
-        specifier: workspace:^
-        version: link:../../subagent/subagent
       '@deepseek-ai/dsh-util-crypto':
         specifier: workspace:^
         version: link:../../util/crypto
@@ -8241,6 +8241,9 @@ importers:
       '@deepseek-ai/dsh-tools':
         specifier: workspace:^
         version: link:../../core/tools
+      '@deepseek-ai/dsh-typert-protocol':
+        specifier: workspace:^
+        version: link:../../typert/protocol
       '@deepseek-ai/dsh-user-approval':
         specifier: workspace:^
         version: link:../../interaction/user-approval
@@ -9049,6 +9052,9 @@ importers:
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../../core/session
+      '@deepseek-ai/dsh-subagent':
+        specifier: workspace:^
+        version: link:../../subagent/subagent
       '@types/react':
         specifier: ~18.3.1
         version: 18.3.31