# dsh as a cheap worker Owner instruction, 2026-09-02 (Discord/session, verbatim): "你再帮我整个dsh,然后 协调通,让dsh里面的agent也当你的sub agent,他们还便宜" — stand up dsh, wire it in so agents running inside dsh act as sub-agents for the coordinator, because they are cheaper. ## What dsh is **dsh = DeepSeek Harness**, DeepSeek's own official agent CLI — `@deepseek-ai/dsh` on npm, MIT, built on the `@deepseek-ai/cordis` plugin kernel (). It is a different agent runtime in the same slot `claude` (GOD_MOD) and the old `dsh web` button occupied — see `WillTools.extension/WillTools.tab/Lab.panel/GodMod.pushbutton/script.py`'s own comment on what it replaced, and `tools/aiextract_run.py`'s module docstring, which documents dsh's local RPC wire contract in detail (dsh serves `POST http://127.0.0.1:3080/api/` with `session.create` / `session.prompt` / `session.history` / `session.cancel`, confirmed against a real reply on 2026-09-01). Config lives under `$DSH_HOME` (default `~/.dsh`): `profiles//` (a patch-layered plugin stack), `settings.yaml` (models/providers), and credentials via a credentials service. `dsh --version` reports `0.1.1-rc.2`. ## The route actually in use — NOT DeepSeek's own endpoint **The owner's own words, 2026-09-02: "dsh我弄好了,不过用的是opencode go的API, 不过一样能用。"** — he got dsh working, but through an *opencode go* API route rather than DeepSeek's official one, and it works the same either way. Confirmed live on this machine by reading his config (never its secret value) rather than assuming: - **Profile**: `headless` — dsh's own one-shot mode, unchanged from the original plan. `dsh --profile headless "task text"`: *"Answer one task, print the final assistant message, and exit"* (its own `--help` text). - **Route**: `~/.dsh/settings.yaml` carries a top-level `agent-default-model` override that the built-in `dsh-agent-default-model` plugin (whose compiled-in default is `provider: deepseek-official`) does not show in `dsh --dump-config` — that command prints the composed *bundle* tree, not the runtime settings-file layer, so the override is real but invisible to that flag. The live override points at provider id **`openocde-go`** (that is the literal string in his config, letters swapped from "opencode" — his file, not a typo this repo should "fix") — declared under `llm-pi-ai.providers.openocde-go` with `api: openai-completions`, `baseURL: https://opencode.ai/zen/go/v1`. - **Model**: `deepseek-v4-flash`, per that same `agent-default-model` override. The provider's own model list also carries `glm-5.3-flash`, `deepseek-v4-pro`, `deepseek-v4-flash-vision-exp`, and `hy4-preview` as options, but `deepseek-v4-flash` is what a plain `dsh --profile headless` call actually boots. - **Credential mechanism**: dsh's own credentials service (`~/.dsh/.credentials.yaml`, the `dsh-credentials-local` plugin), keyed by the env-var name the provider declares (`apiKeyEnv: OPENOCDE_GO_API_KEY`) — **not an OS environment variable**. `OPENOCDE_GO_API_KEY` is unset in every shell on this machine; dsh resolves it through the credentials file instead, which is why the original worker's assumption (`export DEEPSEEK_API_KEY`) never applied here and a plain shell round trip works without that variable being set anywhere visible to `echo $OPENOCDE_GO_API_KEY`. None of this was changed to write this document — it is a description of what was already there, confirmed with `dsh --dump-config`, `dsh --help`, and reading (not printing) `settings.yaml` and `.credentials.yaml`'s key *names*, never their values. **This means the worker must not hardcode a profile.** A different machine with a real `DEEPSEEK_API_KEY` and no `settings.yaml` override gets dsh's stock `deepseek-official` route through the exact same `headless` profile and the exact same `tools/dsh_worker.py` — the profile is the only thing the CLI needs told, and it is now `$DSH_WORKER_PROFILE` / `--profile`, defaulting to `headless`, never baked in. ## A Windows-specific bug this task found and fixed `dsh` installs on Windows as a `dsh.CMD` shim (`npm install -g` puts it at `%APPDATA%\npm\dsh.CMD`) that forwards its arguments to `node \node_modules\@deepseek-ai\dsh\lib\bin.js`. Handing that shim a multi-line task through Python's `subprocess.run([...])` (no `shell=True`) still routes through cmd.exe to resolve the `.CMD` file, and **cmd.exe's own command-line parser treats a newline inside an argument as a command separator — silently truncating the task at the first newline, exit 0, no error.** Measured 2026-09-02: a two-line task reached dsh as its first line alone, and dsh answered a real, well-formed reply to that truncated text — the most dangerous shape of this bug, because nothing about the reply looks wrong on its own; it just answers a different, shorter question than the one sent. `tools/dsh_worker.py`'s `_resolve_launch_cmd` now detects a `.cmd`/`.bat` shim on Windows and calls `node.exe` on the resolved `bin.js` directly, bypassing cmd.exe's argument parsing entirely. Off Windows, or when the shim can't be resolved to a real `bin.js` next to it, the shim is invoked exactly as before. There is also a plain `UnicodeDecodeError` this same code path hit before the fix (Windows console codepage vs. UTF-8 dsh output) — `subprocess.run(..., encoding='utf-8', errors='replace')` is now explicit rather than left to `text=True`'s platform default. ## The worker: `tools/dsh_worker.py` ``` python tools/dsh_worker.py "task text on the command line" python tools/dsh_worker.py --file task.md echo "task text" | python tools/dsh_worker.py - python tools/dsh_worker.py --profile headless --timeout 300 "task" python tools/dsh_worker.py --plain "task text" # reply/reason only, no JSON ``` Prints exactly one JSON object to stdout and sets the exit code to match: ``` {"ok": true, "reply": ""} exit 0 {"ok": false, "refused": true, "reason": ""} exit 1 ``` `$DSH_WORKER_PROFILE` overrides the default profile (`headless`); `$DSH_WORKER_TIMEOUT_S` overrides the default per-call timeout (300s). Both are also settable per-call with `--profile` / `--timeout`. `--plain` is the one non-default output shape — reply text only on success, reason text only on refusal — meant for a human at a terminal; every caller that is code should use the default JSON shape, which is the "structured mode" that lets a caller tell a reply from a diagnostic without parsing prose. `--json` also exists as an explicit no-op for a caller that wants to say so. ### Two real transcripts, captured 2026-09-02 against the live config above **Trivial round trip:** ``` $ python tools/dsh_worker.py "reply with exactly: pong" {"ok": true, "reply": "pong"} $ echo $? 0 ``` (3.5s wall clock.) **Realistic task** — a mechanical rewrite with a checkable output, the exact shape this worker is *for* (docstring text below is illustrative, not a real file in this repo): ``` $ cat task.txt Rewrite the following Python docstring in plain, non-jargon English, at most 3 sentences. Reply with ONLY the rewritten text, nothing else. Original docstring: """ Resolve the effective knowledge index for a job by merging DEFAULTS, the catalogue layer, the plant layer, the job layer, and the project config layer, in that order, and return the merged mapping along with a list of provenance tuples (layer_name, key_path) for every key that was overridden by a layer after DEFAULTS. Raises ConfigMissingError if a layer pointer is present but the referenced file cannot be found on disk. """ $ python tools/dsh_worker.py --file task.txt {"ok": true, "reply": "Merges the default settings with the catalogue, plant, job, and project configuration layers in that order, then returns the combined settings along with a list showing which keys each later layer changed. Raises ConfigMissingError if a layer points to a file that cannot be found."} $ echo $? 0 ``` (10.2s wall clock. The rewrite is accurate and reads well — see "What this is actually good for" below for how far that generalizes.) Both of these were refusals under the original worker before the Windows argv fix and the credential setup landed: the first version of this file recorded a real `MISSING_CREDENTIAL` refusal (correct behaviour for a missing key, at the time it was true), and a naive re-test of the second example — multi-line task text through the un-fixed worker — silently truncated to the first line and returned a plausible-looking but wrong answer, which is exactly the failure mode CLAUDE.md's "could not judge" vs. "judged, found nothing" rule exists to prevent one layer further down than the check that already guards it: this was a transport bug, not a dsh refusal, so no `DshRefusal` fired for it, and the fix had to be in the argv path, not the refusal table. ### Failure/refusal behaviour | what happened | what the worker returns | | --- | --- | | dsh not on PATH | refusal naming the missing binary and the install command | | empty task text | refusal, nothing sent | | dsh exits non-zero | refusal quoting dsh's own stderr verbatim | | dsh times out | refusal naming the timeout budget | | the process cannot even start (e.g. permissions) | refusal naming the OS error | | **dsh exits 0 but prints nothing** | **refusal** — "exited 0 but printed no reply ... could not judge, not judged-empty" | The last row is still the one worth calling out by name: an exit-0, empty-stdout result is the exact silent-pass shape CLAUDE.md's skip-probe table exists to prevent for Ticket QC checks. This worker refuses that shape rather than returning `{"ok": true, "reply": ""}`. Tests: `WillTools.extension/tests/test_dsh_worker.py` — faking the `which()` / `runner()` subprocess boundary, plus dedicated cases for `_resolve_launch_cmd`'s Windows-shim resolution, so the argv-truncation fix is covered without needing a real Windows `.cmd` shim on disk. ## The pool: `tools/dsh_pool.py` `dsh_worker.py` alone is one task at a time. The actual point of a cheap backend is running many small tasks *at once* — that is what `tools/dsh_pool.py` adds: a bounded `ThreadPoolExecutor` fan-out over `dsh_worker.run_task`, N tasks in, N results out, in input order, each result independently `{"ok": true, ...}` or `{"ok": false, "refused": true, ...}`. ``` python tools/dsh_pool.py --tasks tasks.json --out results.json python tools/dsh_pool.py --tasks tasks.json --max-workers 8 --task-timeout 120 ``` `tasks.json` is a JSON array — either plain strings, or objects carrying a `"task"` key plus whatever else the caller wants echoed back (an `"id"`, a `"label"`) so results can be matched by identity rather than array position: ```json [ {"id": "t1", "task": "Reply with exactly: one"}, {"id": "t2", "task": "Reply with exactly: two"}, {"id": "t3", "task": "What is 17 * 3? Reply with just the number."}, {"id": "t4", "task": "In one short sentence, what does 'refactor' mean to a programmer?"} ] ``` Concurrency cap: `--max-workers` / `$DSH_POOL_MAX_WORKERS` (default 4). Per-task timeout: `--task-timeout` / `$DSH_POOL_TASK_TIMEOUT_S` (default: same as `dsh_worker`'s own, 300s). ### A real fan-out run, captured 2026-09-02 (4 tasks, `--max-workers 4`) ``` $ python tools/dsh_pool.py --tasks tasks.json --max-workers 4 [ {"id": "t1", "ok": true, "reply": "one"}, {"id": "t2", "ok": true, "reply": "two"}, {"id": "t3", "ok": true, "reply": "51"}, {"id": "t4", "ok": true, "reply": "Refactoring means restructuring existing code to improve its internal design (readability, maintainability, performance) without changing its observable behavior."} ] $ echo $? 0 ``` 5.6s wall clock for 4 tasks that individually take several seconds each — the fan-out is real, not serialized. `17 * 3 = 51` came back correct; worth noting for the "what this is good for" section below, since a wrong arithmetic answer here would have been a much cheaper thing to catch than one buried in real work. One task failing never takes the batch down: `_run_one` (inside `dsh_pool.py`) catches both `DshRefusal` and any other exception per task and turns it into that task's own refusal-shaped result, so task 3 raising never prevents tasks 1, 2, and 4 from completing or reorders them. The CLI's own exit code reports whether the *batch* ran to completion, not whether every task inside it succeeded — a caller checks each result's `"ok"` field for that, by design: a batch exit code that went non-zero on any single refusal would make a script stop trusting the N-1 tasks that actually worked. Tests: `WillTools.extension/tests/test_dsh_pool.py` — ordering preserved under out-of-order completion, one failure isolated (both a `DshRefusal` and an arbitrary exception), a refusal shape kept distinguishable from a short successful reply, the worker cap actually measured under load (not just accepted as an unused argument), and the tasks-file loading/echo behaviour. ## What to route to it, and what not to — grounded in what was actually observed here, not a guess about model quality The two transcripts above and CLAUDE.md's own measured history line up exactly, and the second is the harder-won lesson: **A cheap-model extraction pass over one document drop in this repo's own history carried a 42% page-error rate on independently sampled pages** — inverted inequalities, misidentified parts, specs blurred into vagueness. **A sub checking its own family's work reported zero errors on the same material** where an independent auditor found that 42%. That is not "dsh is bad" — the two transcripts in this document show it doing accurate, useful work on scoped tasks (a docstring rewrite that reads correctly, correct arithmetic, a fan-out where every one of four small, independent tasks came back right). It is specifically that **a cheap model cannot be trusted to audit itself, or anything else, at the standard this repo demands of a number that lands somewhere consequential** — the checker has to be a different pass, ideally a different model or a human, not the same model grading its own paper. **Route to dsh (cheap, routine, and — this is the load-bearing word — CHECKABLE by something other than the same cheap model):** - A well-scoped drafting task with a clear, verifiable output — a docstring rewrite, a commit-message draft, a regex, a summary of a file — where the caller (a human or a second, independent pass) reviews the output before it lands anywhere that matters. - Mechanical rewrites with a spec the caller can check itself — the docstring example above is the template: input and output are both short enough that verifying the rewrite is *faithful* takes seconds, even though writing it took the model 10 seconds of "thinking." - Wide fan-out where one bad answer in isolation is cheap: N similar small tasks (dsh_pool.py's whole reason to exist) where a wrong result is one wrong row a caller can eyeball or spot-check, not a silent poison in an aggregate. - Summarization and other reductions where "close enough, and skimmable fast" is genuinely the bar, not "exactly right." **Do NOT route to dsh, ever:** - **Anything landing a NUMBER in a knowledge layer.** This is exactly what `knowledge/library/RUNBOOK.md`'s three-stage discipline (extract cheap, audit independently, land off the page) is built to prevent a cheap model from doing unsupervised — a number from a dsh reply is a *note*, never a landed value, and it lands only after a human or an independent pass re-reads the primary source. - **Any audit of another model's work — cheap or not.** The 42%-vs-zero result above is the concrete, measured reason: self-audit (or same-family audit) is not audit, and dsh grading dsh's own output (or a Claude subagent's) inherits that exact blind spot. - **Anything touching the live Revit bridge, a Transaction, or the open model.** Those go through `tools/rvt.py` under a human's own session per CLAUDE.md §1/§3 — not through a detached headless worker with no view of what it is touching and no way to be stopped mid-mutation. - **Anything needing the coordinator's actual conversation context.** A headless `dsh --profile headless` call starts a fresh session with only the task text handed to it — no memory of anything the coordinator has done or decided this session, confirmed by this task's own transcripts (each call above got exactly and only what was in the task string, no more). - Any task whose failure mode is expensive and hard to notice — a plausible wrong number, a wrong fact presented fluently. Cheap-model output for that class of task needs the same landing discipline `knowledge/library/RUNBOOK.md` requires everywhere else: land only off the primary source, never off a model's note, cheap or expensive. ## The exact command a caller uses For one task: ``` python tools/dsh_worker.py "task text" --profile headless --timeout 300 ``` (Omit `--profile` to use `$DSH_WORKER_PROFILE` or the `headless` default; omit `--timeout` for `$DSH_WORKER_TIMEOUT_S` or 300s.) Parse stdout as one JSON object; check `"ok"` before trusting `"reply"`. For a batch: ``` python tools/dsh_pool.py --tasks tasks.json --out results.json \ --max-workers 4 --task-timeout 300 ``` Parse `results.json` (or stdout, if `--out` is omitted) as a JSON array in the same order as `tasks.json`; check each entry's `"ok"` independently — one refused task must never be read as the whole batch having failed, and a successful-but-short reply must never be mistaken for a refusal or vice versa (both are enforced by construction: `"reply"` only appears on `ok: true`, `"reason"`/`"refused"` only on `ok: false`). ## Cost, relative to a Claude subagent DeepSeek's own published API pricing (api-docs.deepseek.com, checked 2026-09-02) for the V4 Flash / Vision-Exp tier: **off-peak input $0.22 / output $0.66 per million tokens (cache-miss), roughly half that at peak hours 01:00-04:00 and 06:00-10:00 UTC weekdays**, with cache-hit input far cheaper still. That undercuts a Claude subagent's per-token API price by roughly an order of magnitude on the numbers checked. The actual route in use here goes through the opencode go API rather than DeepSeek's own endpoint, so that published pricing is DeepSeek's own model cost, not necessarily what opencode go itself charges for the same route — treat the ratio as "cheaper, by a lot," not a guaranteed multiplier, and re-check before quoting a precise number in anything the owner will rely on financially. ## What is NOT built This is a synchronous-per-task, bounded-concurrency CLI bridge. It is not: - a persistent multi-turn session manager (that already exists, in `tools/aiextract_run.py`'s `session.*` RPC client, for the one caller that needs to poll a long-running vision pass); - an automatic task router that decides *for* the coordinator which tasks go to dsh vs. a Claude subagent — that decision is made by whoever is dispatching work, using the routing section above; - a retry/backoff layer — a refused task in a pool run stays refused; a caller that wants a retry re-submits that one task itself, since retrying automatically would blur "this dsh call failed" into "this dsh call succeeded on attempt 2," which is a different, noisier signal than the one this file is built to keep honest.