DSH compatibility contract

August 25, 2026 · View on GitHub

Everything this plugin assumes about DeepSeek Harness, verified against @deepseek-ai/dsh@0.1.1-rc.2 and @deepseek-ai/dsh-sdk-jsonrpc-server@0.1.1-rc.2 on npm (developer preview; DSH promises no compatibility before its first tagged release). --harness checkouts are supported but unverified. On every dsh upgrade, re-verify each row before touching plugin code; each row names its verification command and the plugin file that consumes it.

Distribution

FactVerificationConsumed by
The dsh CLI is published as @deepseek-ai/dsh. Pin the exact version (HARNESS_NPM_VERSION); do not follow latest/next. @deepseek-ai/dsh-sdk-jsonrpc-server is published separately (latest is not the same version as the CLI) and is outside the CLI's dependency closure, so /dsh:setup installs it into the cc profile with dsh plugin add. Dist-tag trap: on 2026-08-24 CLI latest/next were 0.1.1-rc.2 while SDK-server latest was still 0.0.1-rc.5 (next was 0.1.1-rc.2)npm view @deepseek-ai/dsh version; npm view @deepseek-ai/dsh-sdk-jsonrpc-server dist-tags; npm view @deepseek-ai/dsh dependencies has no sdk-jsonrpc-serverhandleSetup, HARNESS_NPM_VERSION
The SDK JSON-RPC server's published peerDependencies are not provided by the launcher's $DSH_HOME/profiles/node_modules self-heal. A profile that only plugin adds the server package fails at boot (Cannot find package '@deepseek-ai/dsh-sdk-protocol'). Setup therefore adds the pinned server and HARNESS_SDK_JSONRPC_PEER_SPECS. --harness still link:-installs packages/sdk/server from the checkout (workspace peers resolve there)npm view @deepseek-ai/dsh-sdk-jsonrpc-server@<pin> peerDependencies; boot dsh --profile cc after addpinnedSdkServerInstallSpecs, handleSetup
The npm CLI entry is node_modules/@deepseek-ai/dsh/lib/bin.js (bin: {dsh: lib/bin.js}). A source-built CLI is not self-contained — workspace deps resolve through the checkout's node_modules, so a --harness checkout must stay installed and built in placenpm tarball layout; apps/cli/package.jsoninstallPinnedDshFromNpm, inspectHarnessCheckout, writeDshWrapper
The harness requires Node ^22.19.0 || >=24 and pnpm (corepack-pinned)apps/cli/package.json engines / root package.jsonselectHarnessNode, setup errors
dsh plugin --profile <p> add <spec...> is a pnpm forwarder; absolute path specs pass through untouched and install as pnpm link:; registry specs install from npm. Multiple specs may be passed in one invocationapps/cli/src/plugin.ts anchorPathSpec; docs/user/develop/basic/publish.mdhandleSetup SDK-server install
@deepseek-ai/dsh-sdk-jsonrpc-server lives at packages/sdk/server in a source tree and is outside the CLI's dependency closure, so the launcher's profiles/node_modules self-heal never provides it — it must be installed into the cc profile's own node_modules (registry spec, or link: from --harness)packages/sdk/server/package.json; grep apps/cli/package.json depsresolveSdkServerDir, pinnedSdkServerInstallSpecs, cc profile design
Built-in profiles are templates materialized in $DSH_HOME on first use (web, headless); non-shipped names exist only after dsh plugin ... add creates thempackages/boot/app-boot/src/profile.ts PROFILE_TEMPLATESprobeProfile failure semantics, setup flow

CLI launcher

FactVerificationConsumed by
Launcher flags (--profile, repeatable --patch <file>) must precede app arguments; the launcher consumes one --, so the bridge always passes the task behind --dsh --profile headless --patch /dev/null -- "say hi"lib/dsh.mjs buildHeadlessArgs
dsh --version prints the launcher version and exits 0dsh --versionlib/dsh.mjs getDshAvailability
dsh --profile <name> --dump-config composes all layers without booting; nonzero exit on a broken profiledsh --profile cc --dump-configlib/dsh.mjs probeProfile
dsh plugin --profile <name> add <pkg> initializes a missing profile (dsh-base template for non-shipped names) and forwards installation to pnpmrun against a scratch DSH_HOMEdsh-bridge.mjs handleSetup
Profiles live at $DSH_HOME/profiles/<name> (default ~/.dsh); the profile's cordis.patch.yml is the user patch layer; CLI --patch files compose after it (last wins)inspect $DSH_HOME/profiles/ccdsh-bridge.mjs handleSetup, overlay design
Profile init seeds cordis.patch.yml as header comments + a bare empty flow array ([]); a block sequence appended after that [] is invalid YAML, so the managed-block writer strips the empty-array line firstcreate a fresh profile via dsh plugin ... add, inspect the filehandleSetup managed patch block

Headless profile

FactVerificationConsumed by
dsh --profile headless -- "<task>" runs one task to completion, prints only the last non-empty assistant message on stdout, exits 0 on a completed final turn / 1 otherwise. The task is a text string; the CLI path this plugin uses has no --image flagrun a trivial task; headless --helplib/dsh.mjs runHeadlessAgent, buildHeadlessArgs
Every headless invocation creates a new session; there is no --resume or session-selection flagheadless README / --helpbroker exists at all

Config rows (dsh-base bundle)

FactVerificationConsumed by
Sandbox mode comes from DSH_PERMISSION_MODEread-only | workspace-write | danger-full-access, read at bootbase bundle cordis.patch.ymllib/dsh.mjs env handling
Approval policy row id approval, field policy; base default ask (fails closed unattended) unless danger modebase bundle patchbuildUnattendedOverlayYaml, cc profile patch
Permission row id permission (@deepseek-ai/dsh-permission-presets): base presets are read-only+ask, workspace-write+ask, danger-full-access+never; boot throws when the composed sandbox+approval pair matches no preset and no defaultPreset is configured; the default preset's knobs are pinned into fresh sessions, overriding the env sandbox modebase bundle patch; packages/interaction/permission-presets/src/index.ts (constructor + pinInitialPermission)buildUnattendedOverlayYaml (defines a matching unattended preset per launch mode; applied to one-shot runs and the broker's --profile cc spawn)
Default model row id agent-default-model with { provider, model }; DeepSeek adapter row id llm-deepseek with { thinking, reasoningEffort }. reasoningEffort schema is 'off' | 'low' | 'high' | 'max' (medium is invalid and fails boot). The plugin overlay accepts low|high|max and defaults to max; it does not expose offbase bundle patch; packages/llm/llm-deepseek schemalib/dsh.mjs buildModelOverlayYaml, VALID_EFFORTS
Base defaults: provider deepseek-official, model deepseek-v4-flashbase bundle patchinformational only — the plugin always overrides with its own defaults (lib/dsh.mjs DEFAULT_MODEL = deepseek-v4-pro, DEFAULT_REASONING_EFFORT = max), applied via the model overlay on one-shot runs and via dsh-broker.mjs initialize + effort overlay on broker spawns
The DeepSeek adapter's advisory catalog defaults to deepseek-v4-flash, deepseek-v4-pro, and deepseek-v4-flash-vision-exp (inputModalities: [text, image]). Unlisted --model ids pass through as text-only routes. Selecting the vision id only changes the model string; this plugin still does not send image content. Plugin default stays deepseek-v4-propackages/llm/llm-deepseek/src/index.ts DEFAULT_MODELS; adapter READMEDEFAULT_MODEL, --model
Files API image upload/reuse and request-time preprocess (resize/format) are adapter-internal. They run when a request already contains image blocks or tool-produced attachments. No cc overlay or plugin flag is required or providedllm-deepseek README (Files API / image policy)informational — not consumed
tool-fs advertises read_image (durable results need ctx.attachments, which dsh-base mounts as attachment-local). That tool can attach a workspace file if the selected route is image-capable. It is not a Claude-paste path. minimal disables the tool-fs rowpackages/fs/tool-fs README; base bundle row tool-fsMINIMAL_MODE_DISABLED_ROWS
hmr row exists in base and must be disabled for a stdio-serving profilebase bundle patchcc profile patch block
A patch row - id: <row> / disabled: true disables a composed row (the headless bundle disables hmr this way), and rows compose by id with the launcher --patch layer last. The minimal mode overlay relies on both, and on the exact row ids in MINIMAL_MODE_DISABLED_ROWS (persona row id system-prompt, tools kept: tool-bash, tool-str-replace-editor). On every dsh upgrade, diff that list against dsh-base's cordis.patch.yml — a renamed or added model-facing row silently widens minimalbase bundle + headless bundle cordis.patch.yml; boot dsh --profile headless --patch <overlay> -- "list your tools"lib/dsh.mjs MINIMAL_MODE_DISABLED_ROWS, buildModeOverlayYaml
tool-bash defaults enableRunInBackground to true, advertising a run_in_background parameter whose description names job_output/job_kill; its admission gate checks the jobs SERVICE (ctx.get('jobs')), not the tool-jobs tools. Minimal disables tool-jobs but keeps the service composed, so without the override a background call would spawn and return a job id the model can neither read nor kill — the overlay therefore pins enableRunInBackground: false (removes the schema knob and hard-rejects undeclared use)packages/shell/tool-bash/src/index.ts (z.boolean().default(true), the ctx.get('jobs') gate)buildModeOverlayYaml tool-bash section
DSH_TOOLS_MODE (native|code|both; other values fail boot) is read by the headless/web bundles' tools row to flip Code Mode process-wide, and is documented upstream as a temporary hook slated for removal once per-session tool-mode selection lands. The plugin strips it from every dsh spawn env — mode ownership belongs to --mode, and Code Mode (PTC) is out of the plugin's scopeheadless/web bundle cordis.patch.yml (mode: !!js process.env.DSH_TOOLS_MODE)lib/dsh.mjs runHeadlessAgent, dsh-broker.mjs spawn env
system-prompt config accepts persona, includeHarnessIdentity (default true — prepends You are an AI agent powered by DeepSeek Harness.), and includeRuntimeContext (default true — sandbox/approval/workspace snapshots as sourced user messages). Both minimal and anchored-standard overlays set both flags false. Remaining tool:bash guidance sections are collapsed by lib/tool-bootstrap.mjs registering ctx.systemPrompt.section({ complete: true }) (a complete: true field on a waterfall-returned section is ignored — complete is captured from the registry before the waterfall) — @deepseek-ai/dsh-persona cannot mount on headless/ccpackages/core/system-prompt READMEbuildModeOverlayYaml, lib/tool-bootstrap.mjs registerCompletePersona / applyCompletePersona
Official Web Minimal uses persistent PTY bash (@deepseek-ai/dsh-tool-bash-persistent, needs an owning Agent + PTY backend) and dsh-fs-local (unsandboxed). This plugin's minimal and anchored-standard keep the sandboxed one-shot tool-bash + sandboxed str_replace_editor. That remaining schema delta is what toolSchemaHashes in a DSH_CC_SNAPSHOT_FILE capture is for. Do not copy dsh-fs-local. PTY bash is a follow-up, not a fourth mode namewebui minimal preset; packages/shell/tool-bash-persistent; packages/fs/fs-locallib/request-snapshot.mjs, docs/testing.md items 13–17
@deepseek-ai/dsh-persona is scope-only: mounting it outside an agent preset collides with deployment:persona and fails loud. Headless/cc have no preset scope, so complete-persona semantics are applied in lib/tool-bootstrap.mjs by ctx.systemPrompt.section({ name: dsh-plugin-cc:persona, complete: true }) plus an outermost assemble filter — not by inserting that packagepackages/preset/persona README ("No global mount")lib/tool-bootstrap.mjs
--patch can insert a row whose name is an absolute path to a local .mjs Cordis plugin (copied into the run's overlays/ next to the yaml). The plugin must use only Node builtins plus relative imports that are copied beside itdsh --profile headless --patch <overlay> --dump-config; boot a trivial taskwriteModeOverlay, copyBootstrapModules
system-prompt/assemble is a waterfall whose result includes sections and tools. Filtering assembled.tools after next() changes the model-visible catalog for that request without disabling composition rows. The assemble listener must register with { prepend: true } so it is the outermost post-transform — an appended listener can otherwise re-add tools/sections after await next(). The next assemble after a durable tool/call or assistant/message on that session can return the full list. Same-step parallel tool calls keep the catalog from the assemble that produced them: the plugin freezes that phase at assemble and holds it until session step/endpackages/core/system-prompt README; session request/headerlib/tool-bootstrap.mjs
Session request/header data is { header: EpochHeader, reason } (initial | resume | change). EpochHeader is { config: LlmCallConfig, adapterDefaults?, system?, tools? }model / maxTokens / reasoningEffort live on header.config, not the header root. DSH_CC_SNAPSHOT_FILE reads that config on the request line. A new assemble replaces the pending snapshot bag so a later pre-step line cannot inherit the previous header's toolspackages/core/session EpochHeader / foldRequestHeader; token-meter event.data.headerlib/tool-bootstrap.mjs requestFromHeader, lib/request-snapshot.mjs
The event is an epoch delta, not a per-request record: those three reasons are the only ones, so a step whose header is unchanged emits nothing while the latest snapshot still reconstructs that step's header. minimal holds one header for a whole run. The recorder therefore keeps the last header per session and writes the wire line at step/end — recording on the event alone left every steady-state step with no source: "request" linerun --mode minimal with DSH_CC_SNAPSHOT_FILE and two tool calls: expect one request line per step, each naming the pairlib/tool-bootstrap.mjs headerBySession
A Cordis context throws on any service property the accessing plugin did not inject (cannot get property "<name>" without inject), so ctx.<service>?.x is a crash, not a probe — and a top-level inject would gate the whole plugin on that service. lib/tool-bootstrap.mjs registers the complete persona inside ctx.inject(["systemPrompt"], …): the dependency scopes to that one effect, so the assemble / pre-step / pre-execute filters still attach on a composition without the registrycordis ReflectService.handler.get; boot any --patch plugin that reads an un-injected servicelib/tool-bootstrap.mjs registerCompletePersona, inject
Tool dispatch may still reach a registered tool that was filtered out of the assemble catalog. 0.1.1-rc.2 still persists assistant/message and the current tool/call before tools/pre-execute, so a live event-log scan at execute time would treat the bootstrap response as already promoted. The plugin therefore freezes the assemble-time phase and, on tools/pre-execute only, returns { kind: "deny", reason } for names outside the bootstrap pair. There is no tool/pre-execute event. If tools/pre-execute is missing on a given pin, the listen is best-effort (caught) and the assemble filter is the remaining controltool-bash README (tools/pre-execute waterfall); tools.md PreToolDecisionlib/tool-bootstrap.mjs

SDK wire protocol (dsh-sdk-jsonrpc-server + protocol package)

FactVerificationConsumed by
Transport: newline-delimited JSON-RPC 2.0 on stdio; stdout is protocol-onlysdk/protocol READMEcc profile design (no stdout logger)
initialize { cwd, provider, model, maxTokens? } → { serverInfo }; serverInfo.name = deepseek-harness-sdk-runtimesdk/protocol typesdsh-broker.mjs ensureInitialized
session/prompt { sessionId, contentBlocks } → { messageId }; an unknown sessionId lazily creates the agent+session pair within this process only. contentBlocks is ContentBlock[] from dsh-llm and may include type: "image". This plugin's broker always sends [{ type: "text", text }]. Slash $ARGUMENTS is text; /dsh:import extractText keeps only type === "text"sdk/protocol SessionPromptParams; dsh-llm ContentBlock; claude-session-transfer.mjs extractTextdsh-broker.mjs contentBlocks
Run-to-idle: wait for the session.event of type agent/inbox/spliced whose data.inserted[].id equals the receipt's messageId, collect events, stop at session.status idle for that sessionIdsdk/client api.ts HarnessSession.rundsh-broker.mjs RuntimeClient.run
Final response = last assistant/message event's data.message.content text blocks, concatenatedsdk/client finalResponsedsh-broker.mjs finalResponse
No cancel and no per-session close on the wire; abandoning a turn means killing the runtime processsdk/protocol Known Limitationsstop semantics, stop --broker

Credentials

FactVerificationConsumed by
DSH resolves DEEPSEEK_API_KEY from env, $DSH_HOME/.credentials.yaml, or project/user .envcredentials package READMElib/dsh.mjs getDshAuthStatus

Upgrade procedure

  1. Read the dsh release notes / changed package READMEs.
  2. Re-verify every row above; update this file in the same commit as any code change it forces.
  3. Run npm test, then the manual acceptance checklist in testing.md against the new dsh.
  4. Bump HARNESS_NPM_VERSION (and HARNESS_SDK_JSONRPC_PEER_SPECS if the server's peers changed) in lib/dsh.mjs and the pin at the top of this file and in the root README. Existing machines pick up the new CLI and profile plugins on the next /dsh:setup (sdkProfileVersion is the identity npm:<pin> or harness:<realpath>, written only after a successful plugin add, so a pin bump, source switch, or failed refresh is retried even if --dump-config already names the package). /dsh:check reports a stale pin or identity until that setup runs. In-memory broker sessions do not survive a pin bump; /dsh:stop --broker is the usual next step after setup.