dsh-acp-rich
已验证@smelt-ai/dsh-acp-rich · v0.1.8 · Apache-2.0
Presentation-complete Agent Client Protocol server for deepseek-harness: streaming text, reasoning, tool cards, inline diffs, plans, usage, commands, permission prompts, and resumable sessions
安装
dsh plugin add @smelt-ai/dsh-acp-rich 用 dsh --profile default --dump-config 确认 layer 已生效 —— 参见安装指南。
源码
标签
作者
说明文档
@smelt-ai/dsh-acp-rich
A presentation-complete Agent Client Protocol server for deepseek-harness (dsh), so a dsh session in smelt looks like a Claude or Codex session: streaming text, reasoning, tool cards, inline diffs, PLAN progress, usage, slash commands, permission prompts, and resume.
Why this exists
The harness already ships @deepseek-ai/dsh-acp. It calls itself an
automation-only ACP server, and its own source says why:
// Emit only committed assistant text. Raw chunks, reasoning, tools, plans,
// titles, and retry markers are presentation or trace data and stay off the
// automation wire.
That is a deliberate product decision, not a gap in the harness — "interactive rendering and human questions belong to the Web host and client modules". But smelt is an interactive client, so connecting it to the automation server yields a plain text box: no tool cards, no diffs, no plan, no resume.
The data was never missing. session/event already carries assistant/chunk
(text and reasoning deltas), tool/call, tool/result, todo/write,
assistant/message.usage, and request/context; ToolDefinition already has
presentCall() / presentResult() returning generic | terminal | diff cards
whose diff shape is field-for-field identical to ACP's ToolCallContent::Diff.
This package subscribes to the same event stream and forwards all of it.
Install and run
Install the package into each native dsh profile that should be available in smelt:
dsh plugin --profile web add @smelt-ai/dsh-acp-rich
The package contributes a dormant Cordis row through its native
dsh.bundle.patch. A normal dsh --profile web invocation keeps using the
profile's original Web or headless host. Smelt starts the same profile with one
final overlay that disables output hosts and activates the ACP row:
dsh-acp-rich --profile web
Models, tools, policies, credentials, settings, persistence, and session IDs
remain owned by the native profile. Smelt discovers installed profiles under
$DSH_HOME/profiles and only replaces the host for its invocation.
The bundle also installs a session lease shared by both hosts. The same session cannot be resumed concurrently from native dsh and Smelt. A lock whose owner process is gone is taken over on the next resume — mkdir is not a kernel lock, so a crashed host must not block recovery.
Querying model capabilities
Reasoning efforts are not a fixed list. resolveModelInfo answers for a
resolved route, so the same model id reports four efforts through a direct
connection and none through a relay, and a connection pinned to
thinking: disabled collapses to off alone. A client that hardcodes the
DeepSeek ladder therefore offers options that fail every request made with them
as soon as the profile points somewhere else.
The package ships a one-shot probe for exactly that question:
dsh-model-capabilities --profile web
It boots the profile's own runtime with every output host disabled, waits for the provider catalog to settle, resolves each advertised model, prints one JSON report on stdout, and exits:
{"providers":[
{"id":"deepseek-official","name":"DeepSeek","models":[
{"id":"deepseek-v4-pro","name":"DeepSeek-V4-Pro",
"efforts":[{"id":"off","name":"Off"},{"id":"max","name":"Max"}],
"defaultEffort":"high"}]},
{"id":"sub2api","name":"Sub2Api","models":[
{"id":"deepseek-v4-pro","name":"Deepseek-V4-Pro"}]}]}
efforts is omitted, not emptied, when a route advertises no reasoning
control, mirroring resolveModelInfo itself. Consumers must read that
absence as "render no picker". Substituting a default list is the bug this
tool exists to remove.
Expect a few seconds per call: assembling the runtime is the only way to get a truthful answer, so the cost is a process launch rather than a file read.
Discovering an endpoint's models
A relay gateway can front dozens of models. Asking the user to retype every id into a settings form is a transcription exercise that also loses the context window and max-token figures the endpoint already publishes.
dsh answers this itself: dsh-llm-pi-ai registers a model discovery that calls
GET {baseURL}/models with the provider's stored bearer token. This package
only exposes it:
echo '{"provider":"sub2api","api":"openai-responses",
"baseURL":"https://example.invalid"}' \
| dsh-discover-models --profile web
{"models":[{"id":"deepseek-v4-pro","name":"DeepSeek-V4-Pro",
"contextWindow":131072,"maxTokens":32768}]}
The request goes over stdin, not argv, because it may carry an apiKey and
argv is world-readable through ps.
Two limits are dsh's, and are deliberate:
- Only
openai-completionsandopenai-responsescan be listed. Azure (api-key header plus an api-version query) and Codex (OAuth) are excluded, and anything else answersDISCOVERY_UNSUPPORTED. That answer means fall back to typing the model in by hand — it is not a failure to retry. - A provider with a built-in catalog answers from the catalog and makes no
network call, so
baseURLis only required for providers dsh does not know.
Credentials come from the profile's own provider map when apiKey is omitted,
which is why the probe waits for the provider catalog to settle before asking.
Skipping that wait yields NO_DISCOVERY first and then a 401, in that order.
Keeping a route's catalog current
A route can be marked auto by the client, meaning "I did not choose these
models; re-read them from the endpoint." dsh refuses a declared route that
resolves no models, so the catalog written to settings.yaml is always a
concrete list — auto is a statement about who maintains it, not a way to omit
it.
dsh-model-settings --action refresh-models writes that refreshed list:
echo '{"id":"sub2api","models":[{"id":"gpt-5.6"}]}' \
| dsh-model-settings --action refresh-models
It sets only the provider's models key. save-custom replaces the whole
provider node, which is correct for an edit the user is watching but wrong for
a refresh that runs unattended at every launch: it would silently drop
headers, timeoutMs, reasoning and every other field a deployment
hand-wrote beside the ones this bridge models. An empty list and an unknown
provider are both refused rather than written.
What is mapped
ACP SessionUpdate |
dsh source |
|---|---|
agent_message_chunk |
assistant/chunk text delta |
agent_thought_chunk |
assistant/chunk reasoning delta |
tool_call |
tool/call + presentCall() |
tool_call_update |
tool/result + presentResult() |
plan |
todo/write |
usage_update |
assistant/message.usage + request/context |
user_message_chunk |
user/message (replay only, see below) |
available_commands_update |
the commands registry |
config_option_update |
ctx.llm catalog + the agent's ModelSelectionRef |
Plus session/load resume, image prompts through ctx.attachments, cancel,
permission prompts, per-session MCP server passthrough, and
session/set_config_option.
Deliberate departures
These are choices, not oversights.
Terminal cards are static. ACP's terminal content variant addresses a
terminalId the client minted via terminal/create. A bridge cannot fabricate
one for a command the harness already owns and runs. So a pending terminal card
shows the command as its title, and the result card is a ```console fence
with an exit/signal pill. Live streaming would require the harness to hand the
command over to the client instead of running it.
Persistent authorization is ours, not the harness's. ApprovalOutcome is
'allowed-once' | 'rejected' | 'cancelled' | 'unavailable' and the source says
'allowed-once' is the only grant — at the core layer, not just in the ACP
bridge. "Always allow" therefore lives in src/grants.ts as an in-memory cache
that sits on the approval/request waterfall and answers matching later
requests itself. It is keyed by tool name within one session, the finest
identity stable across calls (callId is per-invocation, reason is free
prose), and the option labels say exactly that so nobody is surprised by a
broader grant. It is not persisted across restarts.
Live user/message is not echoed. It is either the prompt the client just
sent (double-render) or a synthetic harness injection — skill content, AGENTS.md,
cron notices — that can be enormous. Echo happens only while replaying a loaded
session, where the client genuinely has nothing.
A committed assistant/message renders only if nothing streamed for its
step. This protects non-streaming adapters and pruned-chunk replays without
double-rendering the normal case.
The usage gauge is suppressed without a denominator. contextWindow arrives
on request/context, not with the usage. A number with no scale is worse than
no gauge. Note that the four dsh token counts are disjoint — cached input is
reported separately, not folded into inputTokens — so "used" is their sum.
The model picker writes the selection cell, not a setting. The harness has
no global "current model" to set: whoever creates an agent owns its
ModelSelectionRef, and installModelSelection couples that cell to prompt
assembly and request routing. So this bridge holds the cell for each session and
writes it. agentDefaultModel is deliberately not that seam — it is the
default handed to future agents, so writing it would leave the running
session on its old model while the picker claimed otherwise. It is read exactly
once, at session creation, for the same reason.
Two consequences worth knowing:
- If
@deepseek-ai/dsh-agentcannot be loaded, no selector is published at all. A picker that silently changes nothing is worse than no picker. - Until the bridge knows the route — no configured model, no deployment default
— nothing is published either. The first
request/contextreveals what the session actually ran, and the selectors appear then, viaconfig_option_update. That adoption never overwrites a user's choice.
Switching model drops a reasoning effort the new model does not declare, and
refuses outright if the session already carries images and the target model's
inputModalities explicitly omits image. An adapter that declares no
modalities is treated as unknown, not text-only.
Capabilities are reported from what the deployment composed, never from what
this package can do in principle. No ctx.attachments turns off
promptCapabilities.image; no sessionPersistence provider turns off
loadSession and makes session/load answer -32601, which smelt classifies as
UnsupportedLoad and handles by opening a fresh session. An advertised
capability the runtime cannot serve turns a graceful degradation into a mid-turn
protocol error.
Not implemented
- Permission presets and other
settingssections as selectors. Only the model and reasoning effort are published.registerSessionConfigis the seam for adding more without editing the bridge. - Agent presets as a selector. The harness only lets a session change preset while it is still blank, so a picker that stops working after the first turn would be worse than none.
- Live terminal output, per the terminal-card note above.
sseandacpMCP transports. The harness MCP client speaks stdio and Streamable HTTP only;mcpCapabilitiesreports this honestly, and a server using another transport is skipped with a named warning rather than failing the session.- Compressed session history in smelt's sidebar. smelt reads
session.jsonldirectly and has no zstd decoder, which is why the reference profile setscompression: none. - Image input — blocked upstream, not a choice here. The bridge advertises
promptCapabilities.imagefrom the presence of anattachmentsservice, and the only published implementation,@deepseek-ai/dsh-attachment-local, exists solely on the0.0.1-rc.1train, which will not resolve against a0.1.0-rc.8tree. Until it is republished, a composed runtime reportsimage: falseand that report is accurate. The bridge code path needs no change when it lands; mount the service and the capability flips.
What the unit tests cannot catch
tests/harness.ts fakes the harness's services. It does not model cordis's
loader, and the first live boot failed three times before serving a single
frame — each time with all unit tests green:
export default apply— the loader takes a module'sdefaultas the whole plugin when present, and a bare function carries nonameand noinject. Every first-party harness plugin exports the named triple and nothing else.inject = { required, optional }— this loader'sInject.resolveaccepts an array or aname -> intercept configmap, so that form asked for two services literally namedrequiredandoptionaland the entry waited for them forever (1 entry did not activate).- A profile that fails schema validation —
dsh-session-query-sqliterequirespath, and an invalid block takes down the whole tree, not just its plugin.
tests/plugin-contract.spec.ts now asserts (1) and (2) against the loader's own
normalization rules, plus the package layout that made main dangle. (3) has no
unit-test equivalent: boot the tree after editing the profile. The two-frame
initialize + session/new check under "Install and run" is the whole gate and
it needs no API key.
Publishing
The @smelt-ai org exists on npmjs.org and is separate from the GitHub org of
the same name. Releases are cut by hand from a logged-in machine:
cd dsh-acp-rich
npm version <patch|minor|major> # or edit "version" and commit
npm publish --otp=<code> # prepublishOnly: build → typecheck → test → verify
The --otp is not optional: the publishing account has two-factor auth set to
auth-and-writes, so a publish without a current code fails with EOTP after
building and packing. A granular automation token is the only way to publish
unattended — those bypass 2FA by design.
publishConfig.access is public, which matters: scoped packages publish as
restricted by default, and a restricted publish is rejected outright without
a paid plan.
prepublishOnly exists because of a failure mode npm does not catch on its own.
lib/ is gitignored build output, so packing a tree that was never built
succeeds with exit 0 and produces a tarball containing the README, the bin,
and no source whatsoever. A published version number can never be reused and
unpublishing is restricted after 72 hours, so that mistake is permanent.
scripts/verify-package.mjs therefore asserts against
npm pack --dry-run --json — the real artifact, not a re-implementation of
npm's files semantics — that main, types, every bin, the profile, the
README, and the LICENSE are all present, that more than one module compiled, and
that no test-only file leaked in.
Smelt launches the binary from the selected native profile's dependency tree,
so Node resolves the exact plugin version installed by dsh plugin. The
launch path is only worth changing if smelt also learns to provision that tree.
Upgrades
dsh is a developer preview whose README says "THERE WILL BE
COMPATIBILITY-BREAKING CHANGES", and SessionEventMap, ContentBlockMap, and
ToolCallView are declaration-merged open vocabularies — an upstream rename
never surfaces as a compile error even with hard type imports. That is why this
package restates the shapes it reads (src/harness.ts) behind runtime guards
instead of importing them: an unknown shape degrades to "no card" rather than
throwing inside a session-event listener, and the whole blast radius is one file.
After bumping the supported dsh release train, re-run:
npm test # 161 unit + contract tests, no harness install needed
npm run typecheck
npm run build && npm run verify-package
then boot the tree — a green unit suite has already shipped a bridge that could not start (see "What the unit tests cannot catch"):
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}}' \
| dsh-acp-rich --profile web
then check by hand, against a real session:
- Streaming text and reasoning both arrive incrementally
- An edit shows an inline diff, not a generic card
- A bash call shows the command, then its output and exit status
-
todo_writemoves the PLAN panel - The usage gauge has a denominator
- Slash commands are listed
- A permission prompt appears, and "always allow" suppresses the next one
- ESC cancels mid-turn
- A session resumes from the sidebar with its transcript
- Cross-agent
send_messageworks (proves MCP passthrough) - The model picker lists every provider route, and switching it actually changes the model the next step runs on (a switch lands on the next step by design, not mid-request)
A silent regression in this bridge looks like a missing card, not a crash, so the checklist is the test.
Layout
| File | Role |
|---|---|
src/harness.ts |
Every harness shape this package reads, with runtime guards. The single blast radius for upstream drift. |
src/cards.ts |
Tool-card mapping. A registry, not a switch: registerCallCard / registerResultCard return restore-disposers, so a deployment adds a card without patching this package. |
src/codec.ts |
Pure mappers (chunks, plans, usage, prompt content). |
src/grants.ts |
The self-built authorization cache. |
src/mcp.ts |
ACP mcpServers → mcp-client configuration, mounted per agent scope. |
src/config.ts |
Session config options. Also a registry: registerSessionConfig adds a selector without patching this package. |
src/index.ts |
The cordis plugin: a dispatch table over harness events, plus the ACP method surface. |
src/capabilities.ts |
One-shot probe behind dsh-model-capabilities: reports the reasoning efforts each route actually advertises, so clients need not hardcode them. |
src/discovery.ts |
One-shot probe behind dsh-discover-models: forwards dsh's own GET /models discovery so a relay's catalog need not be retyped. |
bin/dsh-discover-models.mjs |
Its launcher. Reads the request from stdin so an apiKey never lands in ps output. |
profile/smelt-discovery.patch.yml |
Bundle patch that mounts the discovery probe; shipped disabled and enabled only by that bin. |
scripts/verify-package.mjs |
Publish guard. Asserts the real npm pack output carries every file the manifest promises, so an unbuilt tree cannot reach the registry. |