Back to directory

dsh-openclaude-ecosystem

Maintenance: Active

liangz2210-lgtm/dsh-openclaude-ecosystem

Register OpenClaude as a named subagent provider on DeepSeek Harness: task graph, exit-code classification, stall watchdog with auto-resume. Experimental — no real delegation has completed yet.

View on GitHub
$ dsh plugin add dsh-openclaude-ecosystem

Install

dsh has no central install command — add this plugin’s entry (documented in its README below) to your profile or patch config, then restart.

How installs work

0

stars

0

forks

TypeScript

Language

MIT

License

2026-09-15

Created

2026-09-15

Last push

README

dsh-openclaude-ecosystem

Everything needed to run the DSH web profile with the merged dsh-openclaude plugin loaded, which in turn drives OpenClaude (NVIDIA NIM, nemotron-3-ultra-550b-a55b).

Status: experimental, unproven

Read this before cloning. The plugin mounts, builds, and passes its own test suite (57 assertions), but it has never completed a real delegation.

  • 1 real delegation, 0 successes. The only live call (2026-09-11, a single-file HTML game) ran 62 minutes producing nothing and was killed externally, classifying as OPENCLAUDE_NON_ZERO_EXIT.
  • The root cause was not in this plugin. The child process had no read timeout on its HTTP request to NVIDIA and hung on a socket. The stall watchdog shipped to bound that failure (IDLE_TIMEOUT → auto --resume, plus a RUNTIME_EXCEEDED wall-clock cap) has never been exercised against a live stall — the failure predates the fix.
  • It is unguarded by design. --yolo is hardcoded in buildInvocation (executor/openclaude.ts), so a delegated task writes files and runs commands with no confirmation step. Scope workdir deliberately.
  • It needs a third-party CLI you must install yourself. The plugin only spawns openclaude; it holds no credentials. Without that CLI on PATH (or a resolvable fallback, see resolveExecutable) nothing runs.

PLUGIN-STATUS-2026-09-11.md is the full forensic snapshot of that one delegation, taken from process records rather than recollection. Treat it as the honest record of where this stands.

Use it

dsh                 # start, foreground; Ctrl-C stops it
dsh bg              # start detached, wait until it answers, log under logs/
dsh status          # pid / port / HTTP health
dsh stop            # stop it
dsh restart
dsh check           # pre-flight: compose the profile, start nothing
dsh logs 60         # tail the newest background log
dsh where           # every path this depends on

Then open http://127.0.0.1:3080.

dsh is bin/dsh symlinked from ~/.local/bin/dsh (which is on PATH via .zshrc). It is a thin front-end — start-dsh.sh owns the actual launch and the arm64-node guard.

ds is a synonym for dsh (.zshrc: alias ds="dsh"). It previously read cd ~/deepseek-harness && pnpm web, which could never work: the harness root has no web script, so pnpm web fails with Command "web" not found. The real entries are pnpm dsh (tsx source CLI) and pnpm dev:web (frontend dev server).

Driving OpenClaude from inside DSH

The plugin self-mounts at boot — there is no enable step, no per-conversation setup, and nothing to re-arm after opening a new conversation. The commands are registered in the global layer of the command registry, so every session in the running instance shares them. Only a harness restart remounts the plugin.

Three commands exist, and /openclaude is the only one you use routinely:

/openclaude <request>        run one ad-hoc request as a single task
/openclaude tasks/foo.json   run a task-graph file (anything ending in .json)
/openclaude-status           status of every task in the current graph
/openclaude-abort            abort the run and kill in-flight CLI processes

Typing / in the input box opens the command menu (the UI registers itself as the '/' trigger source over commands.list(sessionId)), so /openclaude can be picked rather than typed. The parse rule is /^\/([a-z][a-z0-9_-]*)(?=$|[\t\n\r ])/u, which means:

  • The line must start with / — no leading text.
  • The name is lowercase only, then a space. /openclaude你的需求 and /OpenClaude … both fail to parse and are sent to the model as ordinary chat.
  • Bare /openclaude with no argument parses fine and the handler answers Usage: /openclaude <graph.json | request>.

A command is a trigger, not a mode:

  • It does not go to the model. CommandDefinition.handler is documented as running "against the receiving agent without sending the command to the model", so typing it is a local action, not a prompt.
  • It does not reroute later messages. Nothing in the plugin hooks the session; normal chat is never forwarded to OpenClaude. Every task needs its own /openclaude.
  • Runs do not remember each other. runRequest() builds a one-task graph, runs it, then closes the engine; context comes from files under workdir via collectContext, never from chat history. To continue earlier work, restate it in the request or write it into a graph JSON.

workdir defaults to . in cordis.patch.yml, resolved against the directory dsh was launched from — so start dsh from the project you want OpenClaude to edit, not from $HOME.

Every run is unattended and unguarded. --yolo is hardcoded in buildInvocation (executor/openclaude.ts:215) and no permission-mode is set by default, so nothing asks before a file is written or a command is run. A request like "delete X" is executed without a confirmation step — scope the workdir deliberately, and set permissionMode if you want a narrower mode.

If you want ordinary messages auto-routed to OpenClaude, that is a different integration: it needs a session-level hook the plugin does not currently have.

There is no clarification step, and that is structural

The workflow people expect — DSH clarifies the requirement, proposes a plan, then hands a settled brief to OpenClaude — is not what either half does:

  • /openclaude <string> never decomposes anything. runRequest builds exactly one task: title = the first 72 chars, description = the whole string, acceptanceCriteria: [], no dependencies, then runs it. The string is the brief.
  • The LLM supervisor is a reviewer, not a planner. It runs after an attempt, sees the executor's report plus the acceptance results, and picks one of five actions. escalate marks the task halted — it does not come back and ask you anything.
  • There is no channel to ask you mid-run. The executor is headless (--print --yolo), and the command handler blocks until the run is over.
  • The DSH side cannot act. The web profile disables tool-bash, tool-fs, tool-fs-search, tool-str-replace-editor, plan-mode, tool-subagent*, tool-workflow and more — 24 rows in packages/bundle/web-app/cordis.patch.yml. So the conversational agent can reason with you but cannot read, write or run anything itself.

The two halves therefore talk past each other: the agent can plan but not act, the plugin can act but not plan or ask.

Getting the flow you actually wanted

Do the clarifying in the conversation, then hand over a task graph instead of a sentence. /openclaude <file>.json is the path that gives you decomposition, dependencies, parallelism (maxConcurrency, default 2) and per-task retries (maxRetriesPerTask, default 3):

{
  "tasks": [
    {
      "id": "t1",
      "title": "抽出重试策略",
      "description": "把 payments 的 timeout 重试逻辑抽成独立模块,保持现有行为",
      "acceptanceCriteria": ["npm test -- payments 通过"],
      "priority": 1
    },
    {
      "id": "t2",
      "title": "补指数退避与测试",
      "description": "在 t1 的模块上加指数退避,最多 3 次,并补单元测试",
      "dependencies": ["t1"],
      "priority": 1
    }
  ]
}

An array at the top level works too. Fields are optional except title; description defaults to title, dependencies to [], priority to 1, assignee to openclaude. Because the agent has no file tools, it has to hand you this JSON as text — save it yourself, then run /openclaude tasks/x.json with dsh started from that project.

That is usually not a failure — the command returns one summary line, only after the whole run finishes. There is no streaming into the conversation, and OpenClaude deliberately reports nothing while it is inside a model turn. The journal is the only honest progress view:

tail -n 1 <workdir>/.dsh/openclaude/tasks.jsonl     # default storePath

Each line is a full task snapshot; status, attempts[].errorCode and haltedReason tell you what happened. Useful codes:

errorCode Meaning
OPENCLAUDE_ABORTED the caller's signal fired — a new command or a new conversation cancelled this one
OPENCLAUDE_IDLE_TIMEOUT no progress for idleTimeoutMs (default 240s). A --heartbeat line is liveness, not progress, so it does not reset this clock — the child is alive and getting nowhere
OPENCLAUDE_RUNTIME_EXCEEDED the per-attempt wall-clock cap maxRuntimeMs (default 3600s) was reached. The one failure the progress clock cannot see: a livelock that keeps reporting activity
OPENCLAUDE_CLI_NOT_FOUND openclaude not resolvable; see the broken-symlink note below
OPENCLAUDE_NON_ZERO_EXIT the CLI exited non-zero; the real code is in the result, not an exception

Cross-check liveness without ps (which may be restricted):

lsof -p <dsh-pid> | grep cwd                 # the workdir OpenClaude inherits
pgrep -P <dsh-pid>                           # the openclaude child, if running

Expect runs to be slow: --max-turns 50 by default. --heartbeat 30s is what makes a quiet run distinguishable from a dead one — but note that it only reports liveness; it does not buy patience. A child that heartbeats forever without producing anything is a stall, and idleTimeoutMs ends it. Do not read "it is still heartbeating" as "it is still working". Under an x64 node on Apple silicon (Rosetta) it is slower still.

Those defaults belong to the orchestrator (/openclaude, idleTimeoutMs 240s). The subagent provider below has its own, longer one (900s) — the two are configured separately.

The 编排模式 preset: let the agent delegate by itself

The commands above require you to package work as a task graph. The other path puts the decision inside the agent: a preset whose persona tells it to triage each request, plan, decompose, and hand engineering-heavy work to a child OpenClaude — while keeping clarification, planning, decomposition and acceptance for itself.

It ships as a user preset and is the default:

~/.dsh/.agent-presets/delegating/
  preset.yml           name: 编排模式, order: 5
  agent.cordis.yml     a full copy of `standard`, with two local edits

The two edits, and nothing else:

  1. persona.config.text replaced with the delegation policy (from DELEGATION-POLICY.md);
  2. in the delegation group, the shipped-but-disabled tool-subagent-claude-code row replaced by:
- id: tool-subagent-openclaude
  name: '@deepseek-ai/dsh-tool-subagent'
  config:
    provider: openclaude
    toolName: subagent_openclaude
    enableRunInBackground: true
    maxDepth: provider-managed

Because a preset is a full composition, not a patch, standard is left untouched and the two coexist — pick either from the preset selector, or change agent-presets.default in ~/.dsh/settings.yaml:

agent-presets:
  default: delegating      # or: standard

What the agent gets: subagent_openclaude (foreground by default, or background with run_in_background: truejob_output / job_kill), plus its own subagent and subagent_fork from standard. What it does not get: any ability to see the child's intermediate steps, any --add-dir outside the session cwd, or a child that shares the conversation — inheritsParentContext is false, so the task brief must be self-contained.

The one failure the provider handles without telling anyone is a stall: a child that makes no progress for idleTimeoutMs is treated as hung, killed, and resumed with --resume <its own session id>, up to maxStalledRestarts times. A stall that still reaches the agent therefore means recovery was already exhausted.

"Progress" is the operative word, and it is not the same as "output": OpenClaude heartbeats on stderr every 30s, and those lines prove the process is alive while saying nothing about whether it is working. Treating them as progress is what made the watchdog useless in the first place — the timer was reset every 30s, so a frozen child was never killed and the stall it exists to catch was the one case it could not see. That was observed live on 2026-09-11: a delegation sat frozen for over an hour with its TCP connection static, reported as running. maxRuntimeMs now additionally bounds each attempt for the case a no-progress rule structurally cannot cover — a child that keeps reporting activity while getting nowhere.

Telling the agent the capability exists

Wiring the tool row is only half of it, and for a while it was the half we had. Measured on 2026-09-11, with the preset correct and subagent_openclaude in the catalog: zero delegation calls, and the reasoning trace never once contained subagent, OpenClaude, 委派 or .

The cause is a gap the harness leaves on purpose. Registering a tool and describing a tool are two different acts, and the harness only performs the second one for the single shape it hard-codes: @deepseek-ai/dsh-tool-subagent publishes a tool:<name> prompt section when backgroundMode is continuable. A one-shot provider — ours, and the shipped claude-code provider alike — gets a schema and nothing else. A schema states parameters, not abilities; nothing in it says you can hand engineering work to another process.

So the provider announces itself, in packages/dsh-openclaude/src/subagent/index.ts:

// order 116.5 is where the harness writes its own "delegate in the background"
// text; 116.6 hitch-hikes onto the same tail-of-prompt attention band without
// demoting anyone.
ctx.systemPrompt.section({
  name: `tool:${toolName}`,
  order: 116.6,
  text: context => tools.get(toolName, context.scope) === undefined ? '' : guidance,
})

116 characters, and it names the capability rather than restating the rules — the routing criteria stay in the persona, referenced not duplicated. It reaches systemPrompt and tools through a child fiber (ctx.inject([...])) rather than by widening the row's inject, because a row that pends takes the delegation tool down with it; and the registration tolerates a same-name collision instead of letting a duplicate throw fail the mount. DELEGATION-POLICY.md §11.1 has the four properties and the tests behind each.

DELEGATION-POLICY.md §12 covers the other half of the same update: six additions to the persona (a three-part clarification gate, a "never ask about these" list, recommended answers, rejected-alternatives in decisions, four-way verification classification, and an empty-artifact ban), 1,846 → 2,726 characters, borrowed sentence-by-sentence from github/spec-kit's command prompts while the four places we are stronger — the rework loop, supervision, the red lines, the child report contract — stay untouched.

What lives where

Path Role
bin/dsh the dsh command (start / stop / status / logs / lock recovery)
start-dsh.sh real launcher: verifies arm64 node + harness tree, then execs the CLI
packages/dsh-openclaude/ the plugin: one package, three internal module groups
DELEGATION-POLICY.md the delegation policy — and the source of the preset's persona text (§11 the measured non-delegation record, §11.1 the provider's own prompt section, §12 the six persona additions)
PLAN-agent-plane-delegation.md the A/B route analysis, kept as the record of why B was chosen
PLUGIN-UPDATE-PLAN.md the four-batch update plan this update executed, with the Spec Kit comparison it came from
tools/ acceptance scripts: boot the real profile, mount the preset, assert on the session log (dsh-acceptance-guidance.sh is the one for the current state)
LICENSE MIT

Two directories named below are runtime artefacts and are not in this repo (.gitignore excludes them): logs/ (dsh bg output, one file per start) and backups/ (dated local snapshots, each with a MANIFEST.md and restore commands). They were deliberately left out when publishing — the snapshots carried a live API key in dead test scripts, and neither has value to a reader.

Wiring on the DSH side is four files, all outside this repo:

  • ~/.dsh/profiles/web/package.jsondsh.profile.bundles, which must end with dsh-openclaude
  • ~/.dsh/profiles/web/cordis.patch.yml — the profile patch layer
  • ~/.dsh/profiles/web/node_modules/dsh-openclaude — symlink back into packages/
  • ~/.dsh/.agent-presets/delegating/ — the 编排模式 preset (persona + tool row)
  • ~/.dsh/settings.yamlagent-presets.default: delegating
  • ~/.openclaude.jsonenv block holding the NVIDIA key and default model

The bundle chain carries all eight bundles. On 2026-09-11 a lean pass briefly dropped @liustack/modlens, dsh-vision-router, dsh-find-plugin and @anysearch/anysearch-dsh, cutting the tool catalog from 46 to 16; it was rolled back the same day. DELEGATION-POLICY.md §11 keeps the measurements as the record of the experiment, and says why it was reverted. The round trip cost one line each way, because an entry in dsh.profile.bundles only controls mounting — the packages were never uninstalled from node_modules/. The problem the lean pass was meant to solve was then solved the same day by the opposite means (add a capability statement, remove nothing) — see §11.1 below.

Rolling back also restored attachment-local's image limits (20MiB / 100MP / 10000px), which dsh-vision-router contributes from its own bundle patch. The profile's cordis.patch.yml therefore goes back to carrying no attachment-local row, and it should stay that way: the profile patch layer composes after the bundles and replaces the whole config block rather than merging into it, so a hand-written row would wipe maxImageDimension — the exact trap the bundle's own comment warns about.

Do not delete or move this directory: the profile loads the plugin through that symlink, so a broken link takes the whole profile down at boot. The preset is a separate copy under ~/.dsh/, so editing it does not touch this repo — and vice versa: rebuilding the plugin does not update an already-copied preset.

Notes

  • First boot is slow (measured 5s–167s here): mounting the other bundles is the cost, not this plugin. dsh bg waits up to DSH_START_TIMEOUT (300s).
  • A dsh killed while holding the settings writer lock leaves ~/.dsh/settings.yaml.lock behind and blocks the next boot; dsh clears it automatically when the recorded pid is gone.
  • Environment overrides: DSH_PORT, DSH_START_TIMEOUT, DSH_NODE, DSH_HARNESS_DIR, DSH_ECOSYSTEM_DIR.
  • ~/.homebrew/bin/openclaude was a broken symlink to ~/openclaude/bin/openclaude (a deleted clone), which made bare openclaude fail in the shell while the plugin still worked — resolveExecutable skips symlinks that fail accessSync(X_OK) and falls back to ~/.homebrew/Cellar/node/*/lib/node_modules/@gitlawb/openclaude/bin/. Repointed to ../Cellar/node/26.4.0/bin/openclaude (version 0.30.0). If openclaude ever goes missing again, check that link before anything else.

DSH Plugins is an independent community directory of DeepSeek Harness plugins. Not affiliated with or endorsed by DeepSeek. Third-party plugins are not security-audited — review the source before installing.

New DeepSeek Harness plugins, weekly. No spam.