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 eventsapproval/asked→ (one outcome) →approval/decidedto 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 (evenprependlisteners 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-approvalmust be composed (it is indsh-base); guard withctx.get('approval') !== undefinedlike 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-executewaterfall can return{ kind: 'ask' }; the registry then calls the approval service and the call runs only on'allowed-once'("missing approval support turnsaskinto 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()throwsNO_PROVIDERimmediately — the model-facingask_user_questiontool 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-pendingquestion/requestedframes 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 (rememberDUPLICATE_PROVIDER— you cannot register alongside the web proxy in the same context), or (b) consume the mux stream//api/respondlike 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):
- Each bundle patch in the profile manifest's
dsh.profile.bundles, in list order (@deepseek-ai/dsh-basefirst; web adds@deepseek-ai/dsh-web-app). - The profile's own
$DSH_HOME/profiles/<name>/cordis.patch.yml. - The home-level
$DSH_HOME/cordis.patch.yml(machine-local, all profiles). - 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_HOMEholdsprofiles/<name>/, homecordis.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. webandheadlessprofiles auto-initialize from shipped templates on first use; other profile names must be created viadsh 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 callPOST /api/<namespace>/<method>with{ args }viactx.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, preferctx.webServer.registerroutes 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:
@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 thesession-telemetrycoordinator which listens onsession/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.@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)) fromsubagent/start/subagent/endlisteners — the pattern for event→outbound-notification mapping.@deepseek-ai/dsh-acp(packages/acp/acp): full bidirectional bridge over stdio (Agent Client Protocol) — creates agents onsession/new, forwardsapproval/requestoutward asrequestPermission, streamssession/eventoutward.
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
Appendix A — recommended wiring for dsh-dispatch (all APIs cited above)
- Approval forwarding:
ctx.on('approval/request', (req, next) => …)returningPromise<ApprovalOutcome>; handlereq.signalaborts (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 withctx.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} }); thenagent.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 })orregisterUpgradefor 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.