dsh-agent-bridge
English | 简体中文
Bridge DeepSeek Harness to whatever coding-agent CLI your machine already has — OpenCode, Claude Code, Codex, Gemini, or anything you write a recipe for — and use it as an independent worker for cross-model verification, cheap bulk work, or a second opinion.
The plugin never hard-codes a vendor. An agent is described by a JSON recipe, so adding one — or fixing one whose CLI changed its flags — is a data change, not a code change. Recipes are data on purpose: that is what makes it safe to fetch them from a community registry later.
What it gives you
- A button in the session header. Switch on a pool of candidate agents (each keeps its own model picker), enable any of the three role features, and see what the last run did. An unnamed call uses the first installed pool member;
defaultAgent stays in the state file for 0.3.x consumers.
- Three tools the model can call:
agent_list — which agents were found, where, what they cost, what they support (probe: true also runs --version).
agent_run — run one agent as an independent worker; returns the answer, session id, timing, tool calls and the path of a run report.
agent_mode — read/change the state the button shows.
- A report per run: a Markdown file (prompt, answer, tool calls, tokens, cost, raw log paths) plus
last-run.json for the panel.
Install
From npm (once published):
dsh plugin --profile <your-profile> add dsh-agent-bridge
Or straight from a release artifact, without npm:
dsh plugin --profile <your-profile> add https://github.com/mike-sl-ig/dsh-agent-bridge/releases/download/v0.1.1/dsh-agent-bridge-0.1.1.tgz
Or from a checkout:
dsh plugin --profile <your-profile> add /absolute/path/to/dsh-agent-bridge
Then reload the page (a new client plugin only appears after the page loads the client graph again). Nothing is installed beyond this package: it declares no runtime dependencies and no install-time lifecycle hooks.
Roles
| mode | what the worker is told |
|---|
default | nothing extra |
verify | independent verifier from a different model family; tools forbidden, one pass |
bulk | cheap mechanical worker; prefer direct answers, no self-checking with tools |
second | independent second opinion; tools forbidden, one pass |
The tool prohibition is not cosmetic. Measured: an open-ended verifier task with a tool belt spent 11 tool calls and never answered (180 s timeout), while the same question with tools forbidden answered correctly in 8 s.
Writing a recipe
Drop a JSON file in lib/recipes/. lib/recipes/opencode.json is the reference.
{
"id": "my-agent",
"label": "My Agent",
"cost": "free", // free | paid | unknown
"discover": {
"env": ["MY_AGENT_CLI"], // explicit override wins
"bin": ["my-agent"], // looked up on PATH
"paths": { "win32": ["%LOCALAPPDATA%\\MyAgent\\my-agent.exe"],
"darwin": ["/opt/homebrew/bin/my-agent"],
"linux": ["~/.local/bin/my-agent"] } // `*` segments allowed
},
"version": { "args": ["--version"], "match": "my-agent" },
"run": {
"argv": ["exec", "--json"],
"prompt": { "via": "positional" }, // or "stdin", or a flag name like "-p"
"model": { "flag": ["--model"] },
"session": { "flag": ["--session"] },
"resume": { "flag": ["--continue"] },
"files": { "flag": ["--file"] }
},
"output": {
"format": "jsonl", // jsonl | json | text
"answer": { "where": { "type": "text" }, "pick": "part.text" },
"session": { "pick": "sessionID" },
"tool": { "where": { "type": "tool_use" }, "name": "part.tool",
"input": "part.state.input", "status": "part.state.status", "error": "part.state.error" },
"usage": { "where": { "type": "step_finish" }, "cost": "part.cost",
"input": "part.tokens.input", "output": "part.tokens.output",
"reasoning": "part.tokens.reasoning", "cacheRead": "part.tokens.cache.read" }
},
"caps": { "model": true, "session": true, "resume": true, "files": true },
"models": ["provider/model-a", "provider/model-b"]
}
Field notes:
output.format: "text" treats the whole stdout as the answer — the honest fallback for a CLI you have not reverse-engineered. It is not a guess: nothing is silently discarded.
pick is a dotted path into the parsed object; where is a flat equality filter on the event object.
discover.derive: append templates (node_modules/<pkg>/<entry>) resolved next to a discovered shim. Derived entries always outrank the shim they came from, because a .ps1/.cmd cannot be spawned without a shell. This is how Claude Code works: npm installs claude.ps1/claude.cmd, while the real entry is .../@anthropic-ai/claude-code/bin/claude.exe.
output.errorFlag: a path that forces Ok=false when truthy, even on exit code 0. Claude Code exits 0 and sets is_error instead of failing the process.
output.usage covers both output shapes: with format: "json" the paths are read once from the single object (total_cost_usd, usage.input_tokens, num_turns); with jsonl they are summed over the matching events (step_finish → cost/tokens).
catalog: where the model list really comes from — { kind: "jsonFile", path: [ … ], pick: "models", map: { id, label, credits, vendor }, excludeTags: [ … ], ttlMs }. The first path that reads wins, the result is cached for ttlMs, and any failure (missing file, bad JSON, empty list) degrades to the recipe's own models with the reason in catalogError. models accepts the 0.1.x string[] and { id, label?, tier?, credits?, vendor? } objects.
balance: the account balance, read-only by construction — { kind: "http", base, loginProbe, summary, map, unit }. Exactly the two calls the recipe names are ever issued; no credential means unauthenticated without a request, and the four states (ok / unauthenticated / unsupported / error) never render a made-up number. A balance.write block is rejected by the validator: the plugin cannot describe a call that mutates an account.
interpreter: { "source": "self" } runs a JavaScript agent CLI through the Node that is already hosting the plugin, instead of handing a file to the OS.
Two shapes, both shipped and tested
| recipe | transport | output | cost | gotcha the recipe encodes |
|---|
opencode.json | positional prompt, --format json | JSONL events | free | the answer is the concatenation of type=text events; cost only appears on tool-calling steps |
claude.json | -p + positional prompt, --output-format json | ONE json object | paid | native binary behind an npm shim (derive); a failed run still exits 0 (errorFlag) |
workbuddy.json | -p + positional prompt, --output-format json | ONE json object | paid | the CLI is a .js file, so it needs interpreter.source: "self"; two model catalogs (account-level first, install bundle second) |
WorkBuddy
Tencent CodeBuddy Code. The desktop build ships only the headless bundle (dist/codebuddy-headless.js; there is no dist/codebuddy.js), so the interactive /login command does not exist in this install and the CLI has no login/auth subcommand. Authentication therefore comes from the environment (CODEBUDDY_AUTH_TOKEN); without it the CLI exits with Authentication required and the panel shows 「余额:未登录」 — the honest state, not a number. The model picker still works: it is read from the account-level catalog (%USERPROFILE%\.workbuddy\cache\acc-product-config-v3.json) with the install bundle's product.json as fallback, and each entry carries its 免费 / 付费 x0.21 badge.
Each recipe has a recorded real fixture in fixtures/ (shipped inside the package); npm test replays it, so a recipe whose CLI changed behaviour fails before it fails for a user.
Why a recipe and not code
Recipes are JSON so they can be reviewed and distributed without executing anything. Code-shaped adapters would mean "install this plugin and it will run whatever a registry hands it".
Adding an agent without a release
Both supported ways are data-only:
- Locally —
agent_recipe with action: "draft" runs a CLI once and hands back a draft recipe plus every candidate field it found (the evidence, not just a conclusion). action: "save" validates and writes it to <DSH_HOME>/tools/agent-bridge/recipes/, where agent_list picks it up immediately. A local recipe wins over a bundled one with the same id, so a shipped recipe can be corrected without waiting for a release.
- From a registry —
agent_recipe with action: "import" and an http(s) URL returning a recipe, an array, or {"recipes":[…]}. Every candidate is validated in strict mode (an unknown top-level key is an error, not a warning), oversized and non-JSON payloads are refused before parsing, and every import is recorded in _provenance.json. Nothing in a payload is ever executed — a recipe is JSON read by this plugin's own engine, which is precisely why distributing recipes is safer than distributing adapters.
Publishing
npm test runs fifteen suites offline (no network, no DSH, no agent CLI, nothing to install — the package has no runtime dependencies). pack-check.mjs packs the package with npm pack --dry-run and asserts that the manifest only promises files that are really inside the tarball, that no install-time lifecycle hook exists, and that tests/, node_modules/ and dotfiles stay out. Current payload: 19 files, ~50 KiB, zero runtime dependencies.
CI runs that suite on ubuntu / macos / windows × node 20 / 22, which is what backs the cross-platform claim; the runs that need a real agent CLI (and, for the paid adapter, real credits) are opt-in through LIVE_PAID=1 / LIVE_TOOLS=1 and stay out of CI.
Notes for contributors
- Never auto-execute a discovered binary. Discovery is read-only (
PATH, known install paths, --version). Running an agent requires an explicit call or an explicit default-agent choice.
- Costs are real. A
paid recipe spends the user's money; the panel warns before a paid agent is selected.
tests/parse-recorded.mjs replays every recorded agent stream on this machine through the parser; add a recorded fixture when you add a recipe.
tests/launch-smoke.mjs runs one real agent call end-to-end without DSH.
- Paid recipes are opt-in in the tests too:
LIVE_PAID=1 node tests/host-harness.mjs additionally drives a real Claude Code run. The default npm test never spends money.
- Local-development hazard, measured twice on 2026-09-30. Reconciling a profile that
link:s your working copy — for example swapping that dependency for a packed tarball — can leave the linked directory empty, because pnpm's reconciliation follows the link. The failure is silent, and a profile that links to your only copy will then fail to load the plugin on the next start. Keep the tree somewhere no profile links to (or a backup), and re-run npm test after changing a profile's dependencies.
- Processes are started through
ctx.subprocess when it is available (argv vector, no shell) and fall back to node:child_process with real file descriptors. Pipes are deliberately avoided: an agent CLI whose stdout is an inherited pipe can write its whole answer and then never exit.
License
MIT