dsh Plugin Ground Truth

August 23, 2026 · View on GitHub

Extracted from the DeepSeek Harness source tree at /tmp/deepseek-harness (repo @deepseek-ai/dsh-root, version 0.1.1-rc.2, HEAD b150a551b8d465e31e418e1b2eaf5e79bbb7d28e, 2026-08-21). Every code block cites its source file. All harness docs under docs/subsystems/*.md contain generated "Cordis API" sections produced from source by scripts/gen-cordis-catalog.ts and CI-verified fresh (pnpm run verify-cordis-catalog), so those excerpts are equivalent to source; where behavior mattered I verified against the actual .ts files and cite those.

Target plugin: dsh-dispatch — (1) forward approval requests to a phone and return the decision, (2) report session lifecycle/status, (3) programmatically start agent sessions in a given cwd, (4) send user messages into existing sessions.


1. Plugin anatomy

1.1 Entry-point shape

A dsh/Cordis plugin is a TS/JS module that named-exports apply(ctx, config), optional name (diagnostics label), optional inject (service dependencies), and optional Config (Schemastery schema). Three accepted forms:

// source: docs/cordis-tutorial/01-first-plugin.md (verified against docs/user/develop/basic/index.md)
import { Service, type Context } from '@deepseek-ai/cordis'

// 1. Function plugin (most common).
export const name = 'my-plugin'
export const inject = ['tools']            // required services; apply waits for them
export function apply(ctx: Context) {}

// 2. Object plugin (default export works too).
export default {
  name: 'my-plugin',
  inject: ['tools'],
  apply(ctx: Context) {},
}

// 3. Class plugin: a Service subclass (use only when EXPOSING a service).
export default class MyService extends Service {
  static inject = ['tools']
  constructor(ctx: Context) {
    super(ctx, 'myService')                // claims ctx.myService
  }
}

Everything registered through ctx (listeners via ctx.on, tools, routes) is auto-disposed on plugin unload/HMR. Explicit resources use ctx.effect():

// source: docs/user/develop/basic/index.md
export function apply(ctx: Context) {
  ctx.effect(() => {
    const timer = setInterval(() => console.log('heartbeat'), 5000)
    return () => clearInterval(timer)     // runs when the plugin unloads
  })
}

1.2 Config schema declaration

Export a Config type AND a same-named Schemastery schema (a plain object is rejected — it must implement the Standard Schema interface). Loader validates config and fills defaults before calling apply:

// source: docs/user/develop/basic/config.md
import type { Context } from '@deepseek-ai/cordis'
import Schema from '@deepseek-ai/schemastery'   // first-party code also imports it as `z`

export const name = 'my-plugin'

export interface Config {
  greeting: string
  maxRetries: number
  verbose?: boolean
}

export const Config: Schema<Config> = Schema.object({
  greeting: Schema.string().default('Hello'),
  maxRetries: Schema.number().default(3),
  verbose: Schema.boolean().default(false),
})

export function apply(ctx: Context, config: Config) {
  console.log(config.greeting)
}

Stricter validation: Schema.string().required(), Schema.union(['fast','accurate']).default('fast'). Invalid config fails the plugin load loudly. A config edit hot-replaces the plugin (old instance unloads, new loads).

1.3 package.json — the "bundle" manifest (distribution format)

Installable third-party plugins are bundles: an npm package whose manifest declares dsh.bundle pointing at a cordis.patch.yml layer. Full official example (this is the sample-plugin source the docs ship; examples/ in the repo has no standalone third-party plugin example — the tutorial's hello-plugin below IS the canonical one):

// source: docs/user/develop/basic/publish.md — hello-plugin/package.json
{
  "name": "dsh-hello-plugin",
  "version": "0.1.0",
  "type": "module",
  "main": "index.js",
  "files": ["index.js", "cordis.patch.yml"],
  "dsh": { "bundle": { "patch": "./cordis.patch.yml" } }
}
// source: docs/user/develop/basic/publish.md — hello-plugin/index.js
export const name = 'hello-plugin'

export function apply() {
  console.log('[hello-plugin] plugin loaded!')
}
# source: docs/user/develop/basic/publish.md — hello-plugin/cordis.patch.yml
- insert:
    - id: hello
      name: dsh-hello-plugin

First-party plugin packages use peerDependencies on the harness packages they import (with the same packages duplicated into devDependencies for local dev), "type": "module", main/types pointing into built lib/:

// source: packages/interaction/user-approval/package.json (abridged)
{
  "name": "@deepseek-ai/dsh-user-approval",
  "type": "module",
  "main": "lib/index.js",
  "types": "lib/types/index.d.ts",
  "peerDependencies": {
    "@deepseek-ai/dsh-agent": "workspace:^",
    "@deepseek-ai/dsh-llm": "workspace:^",
    "@deepseek-ai/dsh-scope": "workspace:^",
    "@deepseek-ai/dsh-session": "workspace:^",
    "@deepseek-ai/cordis": "workspace:^"
  },
  "dependencies": { "@deepseek-ai/schemastery": "workspace:^" }
}

For an out-of-tree plugin, depend on published @deepseek-ai/* versions instead of workspace:^. Note git-install caveat: a git-hosted TS plugin needs a self-contained prepare build script AND the user must allowlist it in the profile's pnpm-workspace.yaml (allowBuilds: { dsh-hello-plugin: true }); publishing prebuilt lib/ to npm or shipping a pnpm pack tarball avoids the allowance entirely (source: docs/user/develop/basic/publish.md).

1.4 A realistic complete first-party plugin (command registration)

// source: packages/session-query/session-log-export/src/index.ts (complete file)
import type { Context } from '@deepseek-ai/cordis'
import type { CommandResult } from '@deepseek-ai/dsh-commands'

export const name = 'session-log-download'
export const inject = ['commands']

const REQUESTED: CommandResult = {
  kind: 'success',
  text: 'Session log download requested.',
}

export function apply(ctx: Context): void {
  ctx.effect(() => ctx.commands.register({
    name: 'export',
    description: 'Download this Session log as a ZIP archive',
    handler: invocation => Promise.resolve(invocation.rawInput.trim() === ''
      ? REQUESTED
      : { kind: 'error', text: 'The Web /export command does not accept a path.' }),
  }), 'session-log-download: command')
}

1.5 Cordis in five ideas + dispatch modes (needed to read every event below)

// source: docs/cordis-primer.md
- A plugin registers everything through ctx; a context is a repository of services (ctx.tools, ctx.sessions, …).
- inject declares service dependencies; load order comes from service requirements, not file order.
- Events are typed via declaration merging; registrations are reversible effects.

Dispatch modes:
| Mode      | Awaited? | Order                      | Return value? |
| emit      | No       | registration order         | No            |
| waterfall | No       | registration order         | Yes           |
| parallel  | Yes      | all listeners in parallel  | No            |
| serial    | Yes      | registration order         | Yes           |

Waterfall = around-middleware: a listener receives (...args, next). Call next()
to delegate; return without next() to short-circuit (own the decision).

2. Event bus

Authoritative event matrix: docs/event-producer-consumer.md (generated). Durable session-log event payloads: docs/persistence-catalog.md (generated). Listeners registered on a plain context receive events for all agents; listeners registered on an agent-scoped context (agent.ctx.on(...)) receive only that agent (scope-filtered dispatch via @deepseek-ai/dsh-scope).

2.1 Approvals — approval/request (waterfall) and how to ANSWER one

Declared in packages/interaction/user-approval/src/index.ts:30. Dispatched by ctx.approval.request(req) (the ApprovalService, service key approval, plugin @deepseek-ai/dsh-user-approval).

How a plugin answers: register a waterfall listener on approval/request. Return an ApprovalOutcome (or a Promise of one) to claim the decision, or call next() to pass. The FIRST answer wins. There is no separate "resolve" emit.

// source: docs/subsystems/approval.md (generated from packages/interaction/user-approval/src/index.ts)
/**
 * Ask composed answerers for one decision. Return an outcome to claim the
 * request or call `next()`; failure yields the fail-closed default.
 * Scope-filtered dispatch: agent-scoped listeners receive only that agent.
 * @mode waterfall
 */
'approval/request'(this: Scoped<ApprovalService>, req: ApprovalRequest,
  next: () => Promise<ApprovalOutcome>): Promise<ApprovalOutcome>

type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'
// 'allowed-once' is the ONLY grant. Callers fail closed on everything else.
// A missing, throwing, or non-conforming answerer becomes 'unavailable'.

interface ApprovalRequest {
  /** The agent on whose behalf the question is asked (routing + audit target). */
  readonly agent: Agent
  /** The tool the question is about. */
  readonly toolName: string
  /** The exact tool call being decided, when the asker has one. */
  readonly callId?: CallId
  /** The asker's human-readable explanation of WHY it is asking. */
  readonly reason?: string
  /** Aborting withdraws the question: request settles 'cancelled'; late answers discarded. */
  readonly signal?: AbortSignal
}

type ApprovalRequestId = Branded<'ApprovalRequestId'>
type ApprovalPolicy = 'ask' | 'never'

Real first-party answerer (ACP bridge — the exact pattern dsh-dispatch needs, forwarding to an external channel and resolving from its reply):

// source: packages/acp/acp/src/index.ts:271
ctx.on('approval/request', (request, next) => {
  const record = ownedRecord(request.agent)
  if (record === undefined || request.callId === undefined) return next()
  return conn.requestPermission({
    sessionId: record.agent.session.id,
    toolCall: { toolCallId: request.callId },
    options: [
      { optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' },
      { optionId: 'reject-once', name: 'Reject', kind: 'reject_once' },
    ],
  }).then(({ outcome }) => {
    if (outcome.outcome === 'cancelled') return 'cancelled'
    return outcome.optionId === 'allow-once' ? 'allowed-once' : 'rejected'
  })
})

The web proxy's answerer additionally handles the abort race — settle synchronously if req.signal?.aborted is already true, and add an abort listener that resolves 'cancelled', otherwise a turn cancel leaves the promise pending forever:

// source: packages/host/apiproxy/src/api-proxy.ts:1363 (abridged)
ctx.on('approval/request', (req, next) => {
  if (req.signal?.aborted === true) return Promise.resolve<ApprovalOutcome>('cancelled')
  // … pairs the request with its `approval/asked` audit event via req.callId …
  return new Promise<ApprovalOutcome>((resolve) => {
    const settle = (outcome: ApprovalOutcome): void => {
      req.signal?.removeEventListener('abort', onAbort)
      resolve(outcome)
    }
    const onAbort = (): void => { settle('cancelled') }
    req.signal?.addEventListener('abort', onAbort, { once: true })
    // … push the question to the remote channel; call settle(outcome) on reply …
  })
})

Semantics to respect (source: docs/subsystems/approval.md + api-proxy.ts):

  • ctx.approval.request() requires an open turn; it appends log-only audit events approval/asked → (one outcome) → approval/decided to the agent's session log. Payloads:
// source: docs/persistence-catalog.md (generated from packages/interaction/user-approval/src/index.ts:44,55,67)
'approval/asked':   { id: ApprovalRequestId; toolName: string; callId?: CallId; reason?: string }
'approval/decided': { id: ApprovalRequestId; outcome: ApprovalOutcome }
'approval/policy':  { policy: ApprovalPolicy; source?: 'delegation' }   // last one wins per session
  • The per-session policy runs BEFORE answerers: 'never' deterministically returns 'rejected' without dispatching the waterfall (even prepend listeners can't bypass it). ApprovalService.setPolicy(agent, policy) switches a live agent's policy; overrideOf(session) reads the override.
  • The plugin @deepseek-ai/dsh-user-approval must be composed (it is in dsh-base); guard with ctx.get('approval') !== undefined like apiproxy does.
  • Service config (deployment default):
// source: docs/config-catalog.md — @deepseek-ai/dsh-user-approval (packages/interaction/user-approval/src/index.ts:177)
export interface Config {
  /** 'ask' (default) delegates to composed answerers; 'never' auto-rejects every ask. */
  readonly policy?: ApprovalPolicy
}
  • How approvals arise: the tools pipeline's tools/pre-execute waterfall can return { kind: 'ask' }; the registry then calls the approval service and the call runs only on 'allowed-once' ("missing approval support turns ask into denial") — source: packages/core/tools/src/index.ts (§2.4).

2.2 Agent lifecycle / status events

All declared in packages/core/agent/src/runtime-types.ts, dispatched by @deepseek-ai/dsh-agent-loop. Exact signatures (generated catalog, verified):

// source: docs/subsystems/core.md (generated from packages/core/agent/src/runtime-types.ts)
/** A fully configured agent and live session were published. @mode emit */
'agent/created'(this: Scoped<Agent>, payload: { agent: Agent }): void

/** An agent left the registry (after driver quiescence, before session detach). @mode emit */
'agent/disposed'(this: Scoped<Agent>, payload: { agent: Agent }): void

/** Agent status changed (idle ⇄ running). @mode emit */
'agent/status'(this: Scoped<Agent>, payload: { agent: Agent; status: AgentStatus }): void
type AgentStatus = 'idle' | 'running'

/** The session lifecycle began, once before the first turn. Use agent.inject() to seed context. @mode emit */
'agent/session-start'(this: Scoped<Agent>, payload: { agent: Agent; source: SessionStartSource }): void
type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact'

/** The turn is about to close (model owes no response). Awaited; a listener that
 *  objects calls agent.steer(...) and the machine re-reads its inbox. @mode serial */
'agent/turn-stopping'(this: Scoped<Agent>, payload: { agent: Agent; turn: number; signal: AbortSignal }): Promise<void> | void

/** A step or turn errored. @mode emit */
'agent/error'(this: Scoped<Agent>, payload: { agent: Agent; turn: number; step: number; error: unknown }): void

/** Reject a proposed step or replace the messages that enter it. @mode waterfall */
'agent/pre-step'(this: Scoped<Agent>,
  payload: { agent: Agent; messages: UserMessage[]; turn: number; step: number; signal: AbortSignal },
  next: () => Promise<PreStepDecision>): Promise<PreStepDecision>
type PreStepDecision = { kind: 'reject' } | { kind: 'enter'; messages: UserMessage[] }

/** Inbox notifications. @mode emit */
'agent/inbox/inserted'(this: Scoped<Agent>, payload: { agent: Agent; message: UserMessage }): void
'agent/inbox/claimed'(this: Scoped<Agent>, payload: { agent: Agent; message: UserMessage; turn: number }): void
'agent/inbox/discarded'(this: Scoped<Agent>, payload: { agent: Agent; message: UserMessage }): void

/** A declarative agent entry failed before publishing a live agent. @mode emit */
'agent-loop/config-start-failed'(payload: { sessionId: SessionId; error: unknown }): void

Session store events (declared packages/core/session/src/index.ts:54,64,76,85):

// source: docs/subsystems/session.md (generated from packages/core/session/src/index.ts)
/** Creation announcement; synchronous throw vetoes and rolls back. @mode emit */
'session/created'(this: Scoped<Session>, session: Session): void
/** Emitted once when an announced session leaves the store. @mode emit */
'session/disposed'(this: Scoped<Session>, session: Session): void
/** Post-commit, fire-and-forget append feed — EVERY durable event, live. @mode emit */
'session/event'(this: Scoped<Session>, session: Session, event: SessionEvent): void
/** Awaited parallel durability checkpoint. @mode parallel */
'session/flush'(this: Scoped<Session>, session: Session): Promise<void> | void

session/event is the firehose dsh-dispatch should use for status reporting: it carries turn boundaries (turn/start, turn/end {reason}), messages, tool calls/results, approval/* audits, todo snapshots. Key payloads:

// source: docs/subsystems/session.md (packages/core/session/src/types.ts)
interface SessionEventMap {
  'turn/start': { turn: number }
  'turn/end': { turn: number; reason: TurnEndReason }   // completed|aborted|blocked|error|max-tokens|interrupted
  'step/start': { turn: number; step: number }
  'step/end': { turn: number; step: number }
  'user/message': UserMessage
  'assistant/chunk': { turn: number; step: number; chunk: StreamChunk }
  'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage; interrupted?: true }
  'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string }
  'tool/result': { turn: number; step: number; message: ToolResultMessage; error?: { name: string; code: string }; meta?: JsonValue }
  'todo/write': { todos: TodoItem[] }
  // + request/header, request/context, session/end-seed (log-only)
}
// SessionEvent<T> envelope: { type, seq, time, data, ignorable?, sourceEventSeqs?, surfaceOp? }
// SessionEventMap is merge-extensible; switches must NOT assertNever (plugin-added variants exist).

Subagent lifecycle (declared packages/subagent/subagent/src/index.ts:140-166):

// source: packages/subagent/subagent/src/index.ts
'subagent/provider-added'(provider: SubagentProvider): void            // @mode emit
'subagent/provider-removed'(name: string): void                        // @mode emit
'subagent/start'(this: Scoped<SubagentRuntime>, info: SubagentRunInfo): void   // @mode emit
'subagent/end'(this: Scoped<SubagentRuntime>, info: SubagentRunEndInfo): void  // @mode emit

// source: packages/subagent/subagent/src/types.ts:36,56
export interface SubagentRunInfo {
  readonly runId: SubagentRunId
  readonly provider: string
  readonly id: SessionId        // the child agent's id
  readonly local: boolean
}
export interface SubagentRunEndInfo {
  readonly runId: SubagentRunId
  readonly provider: string
  readonly id: SessionId
  readonly local: boolean
  readonly stopReason: SubagentResult['stopReason']
  readonly lastAssistantMessage?: ContentBlock[]
}

2.3 userQuestions — ctx.userQuestions and the non-web-channel pitfall

Service: UserQuestionService (ctx.userQuestions), plugin @deepseek-ai/dsh-user-questions. Unlike approvals (waterfall, many answerers), questions have exactly ONE active provider:

// source: packages/interaction/user-questions/src/index.ts (verified in source)
export interface UserQuestionProvider {
  ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>
}

registerProvider(provider: UserQuestionProvider): () => void
// throws UserQuestionError 'DUPLICATE_PROVIDER' if one is already registered in the context.

async ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>
// throws: ASK_ABORTED (pre-aborted signal), EMPTY_QUESTIONS, CALLER_NOT_LIVE,
//         DELEGATED_CALLER (agent owned by another agent), BAD_INTENT,
//         NO_PROVIDER ('no user-questions provider is registered')

interface AskUserQuestionRequest {
  questions: AskUserQuestionItem[]
  agent?: Agent          // exact live calling agent
  signal?: AbortSignal   // abort settles the ask
}
interface AskUserQuestionItem {
  id: string; question: string; detail?: string; header?: string
  options?: AskUserQuestionOption[]        // { label: string; description?: string }
  multiSelect?: boolean
  intent?: AskUserQuestionIntent           // { kind: 'plan-review'; approve: string }
}
interface AskUserQuestionAnswer { answers: AskUserQuestionAnswerItem[] }
interface AskUserQuestionAnswerItem { id: string; selected: string[]; custom?: string }

The web proxy is the shipped provider (registers via ctx.userQuestions.registerProvider, pushes a question/requested mux frame, resolves when the browser POSTs /api/respond):

// source: packages/host/apiproxy/src/api-proxy.ts:1310 (abridged)
const disposeProvider = ctx.userQuestions.registerProvider({
  ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer> {
    const sessionId = request.agent?.id
    if (sessionId === undefined) {
      return Promise.reject(new UserQuestionError(
        'web user interaction requires an agent-owned session', 'ASK_MISSING_AGENT'))
    }
    return new Promise<AskUserQuestionAnswer>((resolve, reject) => {
      // registers pending entry keyed by fresh rpcId; abort => claimQuestion + reject ASK_ABORTED;
      // pushes { type: 'question/requested', sessionId, questions } on every mux queue
    })
  },
})

Pitfall (relates to upstream discussion #2544 — the discussion text itself is NOT FOUND in this repo; the mechanism below is verified from source):

  • If NO provider is registered (headless/CLI/non-web compositions), ask() throws NO_PROVIDER immediately — the model-facing ask_user_question tool fails rather than waits.
  • If the web-proxy provider IS composed but no browser client is attached, the returned promise resolves ONLY when a client answers via POST /api/respond. Pending entries deliberately survive client disconnects (mux-open replays still-pending question/requested frames with the same rpcId), so a question asked while nothing is connected hangs indefinitely unless the owning tool/step signal aborts. dsh-dispatch answering from a phone must either (a) be the sole registered provider in its composition (remember DUPLICATE_PROVIDER — you cannot register alongside the web proxy in the same context), or (b) consume the mux stream//api/respond like a client.
  • ask() also hard-fails for delegated (subagent) callers: DELEGATED_CALLER.

2.4 Tool pre/post-execute

Declared in packages/core/tools/src/index.ts (Context Events merge):

// source: packages/core/tools/src/index.ts:152-207 (verified in source)
/** Allow, deny, or ask before dispatch. next() delegates to allow; missing
 *  approval support turns `ask` into denial. @mode waterfall */
'tools/pre-execute'(this: Scoped<ToolRuntime>, exec: ToolExecution,
  next: () => Promise<PreToolDecision>): Promise<PreToolDecision>

/** Around-dispatch waterfall for timeout, retry, or metrics. @mode waterfall */
'tools/execute'(this: Scoped<ToolRuntime>, exec: ToolDispatchExecution,
  next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>

/** Accept, replace, enrich, or block a normalized dispatch result. @mode waterfall */
'tools/post-execute'(this: Scoped<ToolRuntime>, exec: ToolExecution,
  result: Readonly<ToolExecutionResult>, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>

/** Observe the frozen, lossless-JSON final outcome. @mode emit */
'tools/result'(this: Scoped<ToolRuntime>, exec: Readonly<ToolExecution>,
  result: Readonly<ToolExecutionResult>): undefined

/** A tool was registered/unregistered or a scoped restriction changed. @mode emit */
'tools/change'(): void
// source: packages/core/tools/src/index.ts:588-601 (verified in source)
export type PreToolDecision =
  | { kind: 'allow' }
  | { kind: 'deny'; reason: string }
  | { kind: 'ask'; reason?: string }     // runs only after approval returns 'allowed-once'

export type PostToolDecision =
  | { kind: 'accept'; content?: ContentBlock[]; value?: never; additionalContexts?: UserMessage[] }
  | { kind: 'accept'; value: JsonValue; content?: never; additionalContexts?: UserMessage[] }
  | { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: UserMessage[] }

// source: packages/core/tools/src/index.ts:379
export interface ToolExecution extends ToolExecutionInput {
  readonly rootCallId: CallId
  readonly token: ToolExecutionToken
  // inherited: name, parsed deep-frozen arguments, agent?: Agent, callId, signal, …
}

3. Programmatic session creation and message injection

3.1 The service: ctx.agents (AgentRegistry, plugin @deepseek-ai/dsh-agent)

The concrete factory is registered by @deepseek-ai/dsh-agent-loop via ctx.agents.setFactory(); consumers depend ONLY on ctx.agents (inject = ['agents']). Creation APIs:

// source: docs/subsystems/core.md (generated from packages/core/agent/src/index.ts)
/** Create and publish a new agent through the registered factory. */
async create(options: CreateAgentOptions): Promise<AgentHandle>

/** Load a persisted session and resume an agent on it (requires session persistence). */
async resume(options: ResumeAgentOptions): Promise<AgentHandle>

get(id: SessionId): Agent | undefined
list(): Agent[]
roots(): Agent[]              // top-level agents only (no owning agent context)
isOwnedBy(id: SessionId, owner: Agent): boolean
// source: packages/core/agent/src/index.ts:80-156 (verified in source)
export interface CreateAgentOptions {
  /** The live agent/session identity (caller-supplied). */
  readonly sessionId: SessionId
  /** Session creation metadata — validated ABSOLUTE cwd, fork lineage, … */
  readonly meta?: {
    readonly cwd?: string
    readonly parentSession?: SessionId
    readonly seedLength?: number
    readonly origin?: 'subagent'
    readonly delegationDepth?: number
    readonly agentPreset?: string
  }
  /** Initial replay/fork history (balanced completed-turn prefix). */
  readonly seed?: readonly SessionEvent[]
  /** Per-agent options (provider, model, maxTokens). */
  readonly agentOptions?: AgentOptions
  readonly signal?: AbortSignal
  /** Creation-time composition of the agent's scoped world (runs before publication). */
  readonly setup?: AgentSetup
}

export interface ResumeAgentOptions {
  readonly resumeSessionId: SessionId
  readonly agentOptions?: AgentOptions
  readonly signal?: AbortSignal
  readonly setup?: AgentSetup
}

export interface AgentHandle {
  agent: Agent
  dispose(): Promise<void>   // stops loop, awaits exit, unregisters, removes session, unwinds scope
}

export type AgentSetup = (agentCtx: Context) => AgentSetupCommit | Promise<AgentSetupCommit | void> | void
// source: docs/subsystems/core.md (packages/core/agent/src/types.ts)
interface AgentOptions {
  provider?: string   // must have a registered adapter at call time
  model?: string
  maxTokens?: number
}

3.2 How the headless profile does it (apps/cli headless profile → @deepseek-ai/dsh-headless)

This is the canonical end-to-end recipe — create with cwd, send a prompt, await quiescence, flush:

// source: packages/bundle/headless/src/index.ts (abridged; verified complete in source)
import { randomUUID } from 'node:crypto'
import { installModelSelection } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'

export const name = 'headless-runner'
export const inject = ['agentDefaultModel', 'agents', 'sessions']

async function run(ctx: Context, task: string, io: HeadlessIo): Promise<void> {
  // Loader siblings mount concurrently — await the complete application first.
  await ctx.get('loader')?.await()
  const agents = ctx.get('agents')
  const defaultModel = ctx.get('agentDefaultModel')
  const sessions = ctx.get('sessions')

  const selection = defaultModel.currentSelection()
  const { agent } = await agents.create({
    sessionId: SessionId(`session-${randomUUID()}`),
    meta: { cwd: process.cwd() },                                   // <-- cwd here (MUST be absolute)
    agentOptions: { provider: selection.provider, model: selection.model },
    setup: (agentCtx) => {
      const selected: ModelSelectionRef = { current: selection, assembled: undefined }
      installModelSelection(agentCtx, selected)
    },
  })
  await agent.whenIdle()
  const firstSeq = agent.session.seq
  agent.followup(createUserMessage({
    content: [{ type: 'text', text: task }],
    source: { kind: 'user' },
  }))
  await agent.whenIdle()
  await sessions.flush(agent.session)
  // read agent.session.events from firstSeq for the outcome
}

Caveat noted in that file: a deployment that configures an agent-preset roster must join it in setup (ctx.agentPresets.mount(agentCtx, id) / composeFrom) — see packages/preset/agent-presets (docs/subsystems/core.md, ctx.agentPresets).

3.3 Sending a user message into an existing session

The Agent handle (get it via ctx.agents.get(sessionId); for a cold persisted session, await ctx.agents.resume({ resumeSessionId }) first):

// source: docs/subsystems/core.md (generated from packages/core/agent/src/types.ts)
interface Agent {
  readonly id: SessionId
  readonly options: AgentOptions
  readonly session: Session
  readonly inbox: Inbox
  readonly status: AgentStatus            // 'idle' | 'running'
  readonly ctx: Context                   // agent-scoped context

  cancel(cause: AgentCancelCause, options?: CancelOptions): void
  whenIdle(): Promise<void>
  runMaintenance<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T>

  /** Route identified input to an inbox boundary and optionally wake the driver. */
  send(message: UserMessage, target: InboxTarget, wakeup: boolean): void
  /** Queue an ordinary follow-up turn and wake the driver. */
  followup(message: UserMessage): void
  /** Submit steering for the nearest step (idle driver starts a turn). */
  steer(message: UserMessage): void
  /** Queue model-facing context for the next pre-step WITHOUT waking the driver. */
  inject(message: UserMessage): void
}
type InboxTarget = 'next-turn' | 'next-step'
type AgentCancelCause =
  | { readonly kind: 'user' } | { readonly kind: 'parent' }
  | { readonly kind: 'hook'; readonly reason: string } | { readonly kind: 'disposed' }

Messages must be identified (id + source); build them with createUserMessage from @deepseek-ai/dsh-llm:

// source: packages/llm/llm/src/message.ts:100-199 (verified in source)
export interface MessageSourceMap {
  user: { kind: 'user' }
  plugin: { kind: 'plugin'; plugin: string } & ContextFormed
  model: ModelMessageSource
  tool: ToolMessageSource
}
export interface Message {
  readonly id: MessageId
  readonly role: 'system' | 'user' | 'assistant'
  readonly content: ContentBlock[]
  readonly source: MessageSource
}
export interface UserMessage extends Message { readonly role: 'user' }

/** Create one identified user-role message and freeze it before publication. */
export function createUserMessage<T extends NewUserMessage>(
  input: T & { readonly id?: never; readonly role?: never },
): T & Pick<UserMessage, 'id' | 'role'>

Validate liveness before delivery — the SDK server's guard (also the pattern the ACP bridge uses):

// source: packages/sdk/server/src/server.ts:131-143 (verified in source)
if (this.ctx.agents.get(rec.handle.agent.id) !== rec.handle.agent) {
  throw new Error(`session agent was disposed outside the server: ${params.sessionId}`)
}
const message = createUserMessage({ content: params.contentBlocks, source: { kind: 'user' } })
rec.handle.agent.followup(message)
return { messageId: message.id }

Lower-level primitives, if ever needed without an agent: ctx.sessions (SessionStore) — create(id?, options?), prepare/enter/announce, fork(source, boundary?, childSessionId?), flush(session), get(id), list() (source: docs/subsystems/session.md, generated from packages/core/session/src/index.ts).


4. Session query (list sessions, status, last message)

Live sessions: ctx.sessions.list(): Session[] + ctx.agents.get(id)?.status. Whole corpus (live + persisted): ctx.sessionQuery (SessionQueryEngine, Service Definition @deepseek-ai/dsh-session-query; SQLite full-text provider @deepseek-ai/dsh-session-query-sqlite). inject = ['sessionQuery'].

// source: docs/subsystems/session-query.md (generated from packages/session-query/session-query/src/index.ts)
/** List the complete logical corpus using live-preferred records (newest-first). */
listSessions(signal?: AbortSignal): Promise<SessionRecord[]>

/** Read and replay-validate one complete logical session log without making it live. */
async readSession(sessionId: SessionId): Promise<SessionLogSnapshot>

/** Filter the corpus: id / cwd / created-at / parent / availability clauses (ANDed). */
async filterSessions(filters: readonly SessionResultFilter[], signal?: AbortSignal): Promise<SessionRecord[]>

/** Latest log-backed title. */
async readTitle(sessionId: SessionId, signal?: AbortSignal): Promise<SessionTitleSnapshot | undefined>
async readTitleSnapshots(sessionIds: readonly SessionId[], signal?: AbortSignal): Promise<SessionTitleObservationResult[]>

/** Lightweight raw-log event records (ascending seq). */
async listEvents(sessionId: SessionId): Promise<SessionEventRecord[]>

/** Semantic-text scan with filters (seq/time/type/surface/text). */
async filterEvents(sessionId: SessionId, filters: readonly SessionEventResultFilter[]): Promise<SessionEventSearchDocument[]>

/** Current model surface (derived-history order) — use for "last message". */
async readSurface(sessionId: SessionId): Promise<SessionSurfaceSnapshot>

/** One full event plus a bounded raw-log window. */
async readEvent(request: SessionEventReadRequest, signal?: AbortSignal): Promise<SessionEventWindow>

/** Lineage (ancestors + descendant forest). */
async traceSession(sessionId: SessionId, signal?: AbortSignal): Promise<SessionLineageTrace>

/** Full-text search (SQLite provider must be composed; SESSION_QUERY_SEARCH_DISABLED otherwise). */
abstract searchSessions(request: SessionSearchRequest, exec?): Promise<SessionSearchPage<SessionSearchHit>>
abstract searchEvents(request: SessionEventSearchRequest, exec?): Promise<SessionEventSearchPage>
// source: docs/subsystems/session-query.md (packages/session-query/session-query/src/types.ts)
interface SessionRecord {
  header: SessionHeader     // cwd, createdAt, lineage live here
  live: boolean             // currently in ctx.sessions
  persisted: boolean
}
type SessionResultFilter =
  | { kind: 'id'; values: readonly SessionId[] }
  | { kind: 'cwd'; values: readonly (string | null)[] }
  | ({ kind: 'created-at' } & SessionResultRange)
  | { kind: 'parent'; values: readonly (SessionId | null)[] }
  | { kind: 'availability'; values: readonly SessionAvailability[] }

"Last message" recipe: readSurface(id) → last event in events (already in model-history order), or for live sessions agent.session.events filtered by type === 'assistant/message' (see the headless summarize() in §3.2). Live push: subscribe ctx.on('session/event', …).

Error codes: closed union SessionQueryErrorCode (SESSION_QUERY_SESSION_NOT_FOUND, SESSION_QUERY_SEARCH_DISABLED, …) — source: docs/subsystems/session-query.md.


5. Registering a human slash command

Service ctx.commands (CommandRuntime, plugin @deepseek-ai/dsh-commands, package packages/interaction/commands). inject = ['commands']. Commands are executed directly by UI adapters — they never create a model message.

// source: docs/subsystems/commands.md (generated from packages/interaction/commands/src/index.ts)
/** Register a global or calling-agent-scoped command.
 *  Plain-context definitions are global; definitions registered through a
 *  command-injected child of an agent context shadow globals for that agent. */
register(definition: CommandDefinition): () => void

@Remote list(agent: Agent): readonly CommandDescriptor[]
find(agent: Agent, name: string): CommandDefinition | undefined
@Remote async execute(agent: Agent, line: string,
  images: readonly EncodedImageAttachment[], signal: AbortSignal,
): Promise<CommandExecution | undefined>
// source: docs/subsystems/commands.md (packages/interaction/commands/src/index.ts)
interface CommandDefinition {
  /** Lowercase command name without the leading slash. */
  readonly name: string
  readonly description: string
  readonly input?: CommandInputDescriptor      // { hint: string; images?: boolean }
  /** Whether command/run records rawInput. Defaults to true. */
  readonly recordInput?: boolean
  readonly handler: (invocation: CommandInvocation) => CommandResult | Promise<CommandResult>
}

interface CommandInvocation {
  readonly commandId: CommandId
  readonly agent: Agent                        // exact agent whose UI received the command
  readonly rawInput: string                    // exact text after the command name
  readonly attachments: readonly ImageBlock[]  // only if input.images declared
  readonly signal: AbortSignal
}

type CommandResult =
  | { readonly kind: 'success'; readonly text?: string; readonly sourceEventSeq?: number }
  | { readonly kind: 'error'; readonly text: string }

Working registration example: §1.4 above (/export). Another with input hint:

// source: packages/feedback/command-feedback/src/index.ts:100-108
ctx.commands.register({
  name: 'feedback',
  description: 'record feedback about this session',
  input: { hint: '<text>' },
  recordInput: false,
  handler: invocation => executeFeedbackCommand(invocation, ctx),
})

Lifecycle facts: execution appends log-only command/run before the handler and command/done after settlement; commands/change (emit, unfiltered) fires on register/unregister.


6. Config & installation (end-user story)

6.1 Layer model

A profile boots as ordered patch layers over an empty root (source: apps/cli/reference/README.md + docs/user/develop/basic/publish.md):

  1. Each bundle patch in the profile manifest's dsh.profile.bundles, in list order (@deepseek-ai/dsh-base first; web adds @deepseek-ai/dsh-web-app).
  2. The profile's own $DSH_HOME/profiles/<name>/cordis.patch.yml.
  3. The home-level $DSH_HOME/cordis.patch.yml (machine-local, all profiles).
  4. Each --patch <path> overlay, in argv order.

Later layers win per row, and a patch replaces the row's entire config value (no deep-merge). !!js expressions are evaluated against the row's injected context (config: { port: !!js ctx.webStartup.port ?? 3080 }). Both cordis.patch.yml layers are watched and hot-reloaded; bundle-list changes need a restart.

6.2 Install commands

# source: docs/user/develop/basic/publish.md + apps/cli/reference/README.md
dsh plugin --profile demo add ./hello-plugin          # local checkout (link:)
dsh plugin --profile demo add dsh-hello-plugin        # from npm
dsh plugin --profile demo add github:you/hello-plugin#<sha>   # git (needs allowBuilds + prepare script)
dsh plugin --profile demo add ./hello-plugin-0.1.0.tgz        # pnpm pack tarball
dsh plugin --profile demo remove dsh-hello-plugin
dsh --profile demo --dump-config                      # verify the layer without booting
dsh --profile demo                                    # boot
dsh web --patch ./extra.cordis.yml                    # dev: ad-hoc overlay, no install

dsh plugin forwards to pnpm in the profile directory and reconciles dsh.profile.bundles after every run. Resulting profile manifest:

// source: docs/user/develop/basic/publish.md
{
  "name": "dsh-profile-demo",
  "private": true,
  "dependencies": { "dsh-hello-plugin": "link:/path/to/hello-plugin" },
  "dsh": { "profile": { "bundles": ["@deepseek-ai/dsh-base", "dsh-hello-plugin"] } }
}

6.3 Realistic cordis.patch.yml for a hypothetical third-party plugin

Patch grammar as used by shipped bundles (insert adds rows; a bare - id: row overrides an existing row by id; disabled: true turns a row off):

# source pattern: packages/bundle/headless/cordis.patch.yml and examples/web-cordis/cordis.yml (verified)
# dsh-dispatch/cordis.patch.yml — the layer this bundle contributes
- insert:
    - id: dsh-dispatch
      name: dsh-dispatch                  # npm package name; Node resolution finds installed code
      config:
        pushEndpoint: 'https://phone.example.com/push'
        pollIntervalMs: 2000
        approvalTimeoutMs: 300000

And how a user could override that config in their profile's own cordis.patch.yml (restating every key — replace, not merge):

- id: dsh-dispatch
  config:
    pushEndpoint: 'https://other.example.com/push'
    pollIntervalMs: 5000
    approvalTimeoutMs: 300000

Config values are read in apply(ctx, config) after Schemastery validation (§1.2). The full per-plugin config reference is the generated docs/config-catalog.md.

6.4 Filesystem/env facts

  • $DSH_HOME holds profiles/<name>/, home cordis.patch.yml, .credentials.yaml, .env (source: apps/cli/reference/README.md).
  • Credentials resolve: inherited env → $DSH_HOME/.credentials.yaml → invoking dir .env$DSH_HOME/.env.
  • web and headless profiles auto-initialize from shipped templates on first use; other profile names must be created via dsh plugin add.

7. Web server & RPC

7.1 HTTP carrier — ctx.webServer (plugin @deepseek-ai/dsh-host-webserver, packages/host/webserver)

A plugin CAN piggyback routes (this is the sanctioned extension point):

// source: docs/subsystems/web-server.md (generated from packages/host/webserver/src/index.ts)
register(route: WebRoute): () => void            // named route; duplicate (kind,path) throws
registerUpgrade(route: WebUpgradeRoute): () => void   // exact-path HTTP upgrade (WebSocket)
registerFallback(handler: WebRoute['handler']): () => void  // one owner only (SPA dist server holds it)
tapIndex(transform: (html: string) => string): () => void

interface WebRoute {
  kind: 'exact' | 'prefix'
  path: string       // absolute pathname, no trailing slash
  handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>  // may hold open (SSE)
}

/** Gateway config */
interface Config {
  host: '127.0.0.1' | '0.0.0.0'   // only these two values
  port: number                     // 0 = OS-assigned; web default 3080
}

Auth/port: default http://127.0.0.1:3080. "there is no TLS, auth, or origin policy, so a non-loopback bind exposes the server to that network" (docs/subsystems/web-server.md). The CLI currently rejects --host 0.0.0.0 with a usage error; --trusted-host adds named authorities accepted by the /api browser-trust fence (source: apps/cli/reference/README.md). The /api POST channel additionally enforces content-type: application/json as a cross-site write fence (source: packages/host/apiproxy/src/fetch/handler.ts).

7.2 RPC — two layers under /api

  • Typert Gateway (packages/api/gateway, docs/api-gateway.md): business services mark methods @Remote/@RemoteScope; clients call POST /api/<namespace>/<method> with { args } via ctx.remote.<namespace>.<method>(). Strict codegen (typert.host.js / typert.remote-client.js) is part of the repo build — practical for in-tree packages; a third-party plugin can rely on the SRC dev fallback only when the host runs from source. For dsh-dispatch, prefer ctx.webServer.register routes or the events stream instead.
  • API Proxy (packages/host/apiproxy) handles all endpoints without Remote descriptors, including the event streams and answer channel:
// source: packages/host/apiproxy/src/fetch/handler.ts:254-300 (verified)
GET  /api/events.mux      → SSE stream of RpcRequest<MuxFrame>   (all-session aggregated mux)
GET  /api/events.host     → SSE stream of RpcRequest<HostFrame>  (session create/destroy, status flips)
GET  /api/session.export  → session log download (ZIP)
POST /api/respond         → settles pending approval/question server-requests (clientResponseSchema)
POST /api/<other>JSON-enveloped RPC (Typert Gateway first, then API Proxy fallback)

The mux frame vocabulary (what a phone client would consume):

// source: packages/host/apiproxy/src/api/events.ts:69 (verified in source)
export type MuxFrame =
  | { type: 'session/event'; sessionId: SessionId; event: SessionEvent; view?: ToolEventView }
  | { type: 'session/subscribed'; sessionId: SessionId; lastSeq: number }
  | { type: 'approval/requested'; sessionId: SessionId; approvalId: ApprovalRequestId; toolName: string; callId?: CallId; reason?: string }
  | { type: 'approval/resolved'; sessionId: SessionId; approvalId: ApprovalRequestId; outcome: ApprovalOutcome }
  | { type: 'question/requested'; sessionId: SessionId; questions: AskUserQuestionItem[] }
  | { type: 'question/resolved'; sessionId: SessionId; questionRpcId: RpcId; outcome: 'answered' | 'cancelled' }
  | { type: 'session/queue'; sessionId: SessionId; items: QueuedInboxItem[] }
  | { type: 'session/jobs'; sessionId: SessionId; jobs: JobView[] }
  | { type: 'session/projection'; sessionId: SessionId; key: string; value: unknown; seq: number }
  | { type: 'stream/error'; error: RpcError }

On mux open, the server emits a session/subscribed frame per attached session then replays each still-pending approval/question requested frame with its original rpcId (refresh-recovery baseline) — source: packages/host/apiproxy/src/api/events.ts (EventsApi.mux JSDoc).

Note: "events.mux" is an SSE GET route, not a WebSocket; the upgrade registry (registerUpgrade) exists for plugins that want a real WebSocket.


8. Notification/IM precedent

No first-party webhook/IM notifier plugin exists (searched packages/ for webhook/notifier/notification — only hit is the SDK protocol package). Closest outward-push precedents:

  1. @deepseek-ai/dsh-session-telemetry-otel (packages/session/session-telemetry-otel/src/index.ts): pushes every projected session event outward as OTLP/HTTP log records — composes the OTel JS SDK (LoggerProvider + BatchLogRecordProcessor + OTLPLogExporter), fed by the session-telemetry coordinator which listens on session/event / session/flush. Env knobs: DSH_TELEMETRY_MODE=FULL|FEEDBACK_ONLY, DSH_TELEMETRY_OTLP_URL, DSH_TELEMETRY_DISABLED (source: apps/cli/reference/README.md). This is the architectural template for dsh-dispatch's status reporting: subscribe the session firehose in a host-plane plugin, batch, push over HTTP, own an explicit shutdown flush deadline.
  2. @deepseek-ai/dsh-sdk-server (packages/sdk/server/src/server.ts): pushes lifecycle notifications outward over a JSON-RPC transport (transport.notify('subagent.started' | 'subagent.finished', payload)) from subagent/start/subagent/end listeners — the pattern for event→outbound-notification mapping.
  3. @deepseek-ai/dsh-acp (packages/acp/acp): full bidirectional bridge over stdio (Agent Client Protocol) — creates agents on session/new, forwards approval/request outward as requestPermission, streams session/event outward.

9. Brand / naming

Source: BRAND_GUIDELINES.md (root). Rules relevant to third-party plugins:

  • You MAY say "built on DeepSeek Harness" / "compatible with DeepSeek Harness" in descriptions.
  • For project NAMES: use the abbreviated "DSH" designation — recommended for ecosystem association. Do NOT use the full "DeepSeek Harness" trademark in a project name (registered trademark of DeepSeek).
  • Don't use official brand materials implying endorsement.

The dsh- package prefix is explicitly the convention the official plugin tutorial itself uses for a third-party package (dsh-hello-plugin, docs/user/develop/basic/publish.md), so dsh-dispatch is an on-convention name. First-party packages are namespaced @deepseek-ai/dsh-*; do not publish under @deepseek-ai/.


10. Versions

// source: /tmp/deepseek-harness/package.json + git log -1
repo version:     0.1.1-rc.2   (@deepseek-ai/dsh-root, private monorepo root)
git HEAD:         b150a551b8d465e31e418e1b2eaf5e79bbb7d28e
                  Fri Aug 21 20:03:37 2026 +0800
                  "Merge pull request #2908 from deepseek-harness/release/dsh-0.1.1-rc.2"
node:             "engines": { "node": "^22.19.0 || >=24.0.0" }
package manager:  pnpm@11.7.0  (pnpm ≥10 build-script allowlist semantics apply)
license:          MIT
type:             module (ESM throughout)
typescript peers: '>=5 <7'
vendored fx:      @deepseek-ai/cordis (vendor/cordis), @deepseek-ai/schemastery, @deepseek-ai/cosmokit

  • Approval forwarding: ctx.on('approval/request', (req, next) => …) returning Promise<ApprovalOutcome>; handle req.signal aborts (pre-aborted → 'cancelled' synchronously; abort listener → settle 'cancelled'); next() when the phone channel is unavailable so the chain falls through to fail-closed 'unavailable'. Guard composition with ctx.get('approval') !== undefined. §2.1.
  • Status reporting: ctx.on('agent/status'|'agent/created'|'agent/disposed' |'agent/session-start'|'agent/error', …) + ctx.on('session/event', …) for turn boundaries; batch-push outward like session-telemetry-otel. §2.2, §8.
  • Start session from phone: inject = ['agents', 'agentDefaultModel']; await ctx.get('loader')?.await() before first create; ctx.agents.create({ sessionId: SessionId(\session-${randomUUID()}`), meta: { cwd: absolutePath }, agentOptions: {provider, model} }); then agent.followup(createUserMessage({ content: [{type:'text',text}], source: {kind:'user'} }))`. §3.2.
  • Message into existing session: ctx.agents.get(id) → validate identity → followup(); cold session → ctx.agents.resume({ resumeSessionId: id }). §3.3.
  • List/status query: ctx.sessionQuery.listSessions() / readSurface(id) / readTitleSnapshots(ids). §4.
  • Optional phone-facing HTTP: ctx.webServer.register({ kind:'prefix', path:'/dispatch', handler }) or registerUpgrade for a WebSocket — but note the server is loopback, no auth/TLS; an outbound-push (webhook/long-poll to the phone relay) avoids exposing the host. §7.