Files
deepseek-harness/docs/cookbook/extension-cookbook.md
Turtle f290a8b851 refactor(cli)!: one shared base config with per-surface overlays
`dsh` shipped two config trees that were 43 rows the same: apps/cli/cordis.yml
composed web as 74 flat rows, while the TUI booted examples/tui-agent/cordis.yml
whose single `@deepseek-ai/dsh-tui-demo` row mounted twelve plugins behind a
twenty-key pass-through Config. Neither file was what its location claimed —
apps/cli hardcoded the "example" as the product default and the "demo" bundle
was the application — and every capability change had to be made twice.

- apps/cli/base.cordis.yml holds the 43 shared rows; tui.cordis.yml and
  web.cordis.yml are patch lists stating only what differs per surface
- overlays apply as SIBLING patch lists at one include level, because include
  patches never cross an include boundary. Precedence: base < surface <
  (--config | personal ~/.dsh/config.yaml) < launcher flag/profile patches
- `--config` now applies an overlay INSTEAD OF the personal one, so a demo or
  test tree never inherits the user's route; new `--config-replace` boots a file
  as the entire tree (the old `--config` behaviour). Both survive /resume
- vendor/include: index each `insert`ed row as it is added so a later patch can
  configure or disable it. Upstream built the id index once before the patch
  loop, leaving every surface-only row — the whole TUI front door — silently
  unpatchable from user config. Logged as local modification 8
- session identity moves to dsh-agent-loop's CONFIGURED_AGENT_IDENTITIES_KEY;
  dsh-tui's MAIN_SESSION_ID_KEY is deleted (only the bundle read it)
- delete examples/tui-agent, examples/cordis-agent, packages/examples/tui-demo;
  TUI tests → apps/cli/tests, cordis e2e → packages/cordis/tool-cordis/tests,
  examples/code-mode survives as an overlay leaf
- `dsh web` gains --config, threaded into AppCLIEntry as an extra overlay

Three latent defects surfaced and are fixed here: the TUI captured the optional
sessionQuery service once at construction and could permanently disable /resume
when it won the mount race; the session-store root silently reverted to a
project-local ./.sessions; --config-replace was dropped by the resume handoff.

Verified by booting each tree through the real Loader (TUI 55 entries, web 75,
zero unsettled) rather than reading YAML. All eight terminal snapshots replay
byte-identically; 14/14 PTY smoke, 112/112 snapshots, 25/25 doc-sync, hygiene
and lint clean.
2026-07-29 21:15:42 +08:00

10 KiB

Cookbook: extension plugin shapes

English | 中文

FIXME: This important guide has not received sufficient human design review; complete that review before the first release.

The three plugin shapes you write against the harness extension surface, as illustrative snippets (elided imports and helper stubs — not copy-paste-complete). For the full step-by-step guides see adding a package, adding a tool, and adding an LLM adapter; for the seams these hook into see docs/architecture.md.

A tool plugin

A tool registers on ctx.tools. The annotated defineTool example (typed execute args, result shaping, the run_in_background pattern) lives in adding-a-tool.md — that guide is the source of truth for the tool shape. Raw JSON-Schema ToolDefinitions are also accepted by ctx.tools.register() directly (that is how MCP-sourced tools arrive); defineTool is the typed sugar for first-party tools.

A hook plugin (permission-gate example)

This permission gate is one example of a hook plugin. It returns a typed decision from the tools/pre-execute gate to allow or deny a call; sandbox, permission, and plan-mode plugins can use this seam. Hook plugins can intercept other seams and are not inherently permission gates. A "native hook" is an ordinary Cordis plugin on an interception seam; it needs no external protocol.

import type { Context } from 'cordis'
import type { PreToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools'

declare function isAllowed(exec: ToolExecution): Promise<boolean>

export const name = 'permission-gate'

export function apply(ctx: Context) {
  ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
    if (!(await isAllowed(exec))) {
      return { kind: 'deny', reason: 'Denied by policy.' }
    }
    return next()
  })
}

This waterfall is the reorderable policy layer. Use ctx.tools.guard() when an invariant needs a monotonic final denial, tools/execute when a plugin must wrap the actual dispatch lifetime (timeouts/retries/metrics; only exec.signal is replaceable), tools/post-execute for explicit result transformation, and tools/result for contained observation of the immutable final outcome. The adding-a-tool guide gives the selection rule.

A UI plugin

A UI plugin renders from the session/event feed (the assistant token stream as assistant/chunk, plus turn/step boundaries and tool activity), and drives input back in via agent.followup() / agent.steer().

import type { Context } from 'cordis'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'

declare function render(text: string): void
declare function onUserInput(handler: (text: string) => void): void

export const name = 'my-ui'
export const inject = ['agents']

export function apply(ctx: Context) {
  ctx.on('session/event', (_session, event) => {
    if (event.type === 'assistant/chunk' && event.data.chunk.type === 'text-delta') {
      render(event.data.chunk.text)
    }
  })
  onUserInput(text => ctx.agents.get(SessionId('client-session'))?.followup(createUserMessage({
    content: [{ type: 'text', text }],
    source: { kind: 'user' },
  })))
}

An external protocol driver

A protocol driver adapts a wire peer to ctx.agents; it may serve a UI or an automation client. A stdio driver owns stdout, creates or resumes agents through the factory, maps the protocol's requests to followup() or cancel(), and settles each request exactly once from durable turn/end. Tear agents down with AgentHandle.dispose() so disposal reaches quiescence.

packages/acp/acp is the automation-only worked example: it exposes fresh text sessions over Agent Client Protocol JSON-RPC stdio, emits committed assistant text, and registers a one-shot machine permission answerer for agents it owns. Its README owns the exact method and lifecycle contract.

import type { Context } from 'cordis'

export const name = 'my-protocol-bridge'
export const inject = ['agents', 'sessions', 'sessionPersistence']

export function apply(ctx: Context) {
  // Stream every logged assistant text/reasoning delta out to the client.
  ctx.on('session/event', (_session, event) => {
    if (event.type === 'assistant/chunk') {
      const chunk = event.data.chunk
      if (chunk.type === 'text-delta') {
        // sendToClient({ kind: 'message_chunk', text: chunk.text })
      }
    }
  })
  // Inbound "prompt": create/resume an agent and feed it; settle on turn end.
  // Teardown reaches quiescence via AgentHandle.dispose() (stop + await exit).
}

Runnable wirings

Runnable leaves load their plugin trees from examples/*/cordis.yml; the root demo:* scripts and those leaf directories are the authoritative inventory. Interactive leaves use @deepseek-ai/dsh-tui, non-interactive leaves use @deepseek-ai/dsh-cli-demo, ACP leaves use @deepseek-ai/dsh-acp-demo, and the app packages share @deepseek-ai/dsh-agent-spine-demo.

The feature → mechanism map

Every product feature maps to a listener on a documented extension seam — the microkernel claim made checkable (microkernel Agent Note). No row modifies the loop.

system-prompt/assemble is an expert cooperative whole-assembly transform: its returned assembly is authoritative, so listener authors own preserving active Code Mode and structured-output protocol contributions. Prefer ctx.tools.restrict() for tool filtering that must stay aligned across presentation, lookup, and execution.

Product feature Plugin mechanism
Hook system (user + project level) listeners on agent/session-start, agent/prompt-submit, agent/request, tools/pre-execute, tools/post-execute, and agent/turn-stopping; the waterfall seams return typed decisions, while agent/turn-stopping may steer another step; the dsh-hooks-claude / dsh-hooks-codex bridges map hook config files onto these seams
/goal ctx.goals owns durable state, dsh-goal-session schedules same-session rounds through the public Agent, and separate command/tool producers expose human/model control
/loop on the turn/end session event, followup() the next iteration; or force-continue
Dynamic workflow ctx.workflows + the worker-thread engine + the workflow tool; structured in-process children enforce output with scoped prompt/tool registrations, a monotonic tool guard, final tools/result commit (including enclosing run_code), and the structured-output execution's monotonic concludeTurn() marker
Queued + steering messages core Agent.followup() / Agent.steer()
Context compaction (auto + manual) the ctx.compact seam + dsh-compact-basic; automatic pressure runs on serial agent/step, canonical overflow recovery runs on agent/request-error, and manual callers use the same compact service (compaction Agent Note — the model-facing /compact consumer tool is deferred)
System prompt configurability ctx.systemPrompt.section() with ordering and scope-local shadowing
AGENTS.md (root) a section provider reading the file
AGENTS.md (subdir, on-touch) + file-change notices agent.inject() from a watcher / tool-result listener
Built-in tools ctx.tools.register(); schemas flow into the assembly automatically — the dsh-tool-* families (bash, fs, web, subagent, todo) are the shipped examples
ToolSearch / progressive disclosure replace a scoped ctx.tools.restrict() registration as the visible set changes; the registry keeps presentation, lookup, and execution aligned
Tool deadline / retry / metrics wrap core dispatch with tools/execute; a wrapper may replace exec.signal, delegate, and inspect the normalized result in one lexical lifetime
Final tool-result metrics / audit / capture observe immutable authoritative outcomes with tools/result; use tools/post-execute instead only when the plugin must transform the result or attach context
Monotonic terminal turn policy call ToolExecution.concludeTurn() from the successful terminal tool; later tool calls in the same response remain guardable, and the loop stops after the step
Subprocess sandbox (landlock / sandbox-exec) use a ctx.sandbox backend through dsh-bash-sandbox; use tools/pre-execute for capability-level denial
Permission system / AskUserQuestion return ask from tools/pre-execute and answer through ctx.approval; register a separate model-facing ask tool for ordinary user questions
Plan mode Shipped: @deepseek-ai/dsh-plan-mode — logged plan/mode state, the plan:policy guidance section, /plan [message] entry, /plan off direct exit, and the user-reviewed exit_plan_mode exit; enforcement stays on the independent sandbox/approval axes
Sub-agent delegation the ctx.subagents provider registry (dsh-subagent-spawn/-fork/-acp) + dsh-tool-subagent exposing one configured provider to the model
MCP one plugin per server: discover tools → ctx.tools.register()
Skills section + tool registration; inject() skill content on invocation
Memory section provider + tool
Scheduled tasks (cron) a plugin registers model-callable scheduling tools; timer fires → followup(…, {source: {kind: 'cron', …}}) when idle / inject() notification when busy
UI (GUI; CLI emits JSONL) listen session/event (assistant chunks, boundaries, tool activity); input → followup()
Telemetry / replayable trace session/event → JSONL; replay = sessions.create(id, { seed })
Model adapters LlmAdapter subclass via registerAdapter (dsh-llm-deepseek, dsh-llm-pi-ai)
Plugin hot-reload every registration is a ctx.effect → vendored HMR just works