Install
Inside DeepSeek Harness, with dsh-market
dsh plugin --profile web add dshmarket
Or from the command line
dsh plugin --profile web add github:shenkonghui/dsh-llm-acp
Installing runs third-party code with your own permissions — it can read your files, use your credentials and reach the network. Review the source first, and pin a commit (github:owner/repo#sha) when you can.
README
中文 | English
ACP-client LLM adapter + ACP Servers settings UI for DeepSeek Harness. Drives an external Agent Client Protocol server as a model provider on the harness LLM seam, with a web settings page for browsing the ACP registry and managing configured servers.
This package is a dual-face dsh plugin: the host half (lib/index.js) is a transport adapter that registers provider routes on ctx.llm; the client half (lib/client.js) is a browser settings section that lets users browse the ACP registry and add/remove ACP agent servers from the web UI.
Install
dsh plugin --profile my-acp add github:shenkonghui/dsh-llm-acp
Or from a local checkout:
dsh plugin --profile my-acp add ./dsh-llm-acp
Built artifacts (lib/) are committed to the repository, so no build scripts run during install.
Uninstall
dsh plugin --profile my-acp remove @deepseek-ai/dsh-llm-acp
This removes the dependency and the bundle layer from the profile.
Configure
After installation, open Settings → ACP Servers in the web UI. Browse the ACP registry, click 添加 on any agent (e.g. Devin, Codex, Claude Agent), and it becomes a configured ACP server. Each configured server creates an independent provider route acp-<server-id>.
In the My Servers tab, click Edit on any configured server to:
- Set environment variables for authentication (e.g.
DEEPSEEK_API_KEY,OPENAI_API_KEY). Per-server env is merged on top of the plugin-levelenv. - Select which models to expose from the server's discovered catalog. Leave empty to expose all discovered models.
ACP servers do not store separate permission policies. They use the existing session permission list: read-only and workspace-write forward sensitive operations to the harness approval UI, while danger-full-access allows them automatically.
Alternatively, configure servers directly in settings.yaml:
llm-acp:
servers:
devin:
command: devin
args:
- acp
name: Devin
env:
DEEPSEEK_API_KEY: sk-xxx
models:
- deepseek-chat
- deepseek-reasoner
How it works
Host half — LLM adapter
apply(ctx, config) reads the llm-acp settings namespace for configured servers. For each server, it spawns a long-lived child process, opens an ACP ClientSideConnection over stdin/stdout, and registers an AcpAdapter on ctx.llm under route acp-<server-id>. Each model call opens a fresh ACP session, sends the full conversation as one user message, and translates streamed agent_message_chunk updates into harness StreamChunks.
Client half — Settings UI
The browser half registers a settings.section slot that renders the ACP registry browser and a "My Servers" list. Adding a server persists it to the llm-acp settings namespace; the host half observes the change and reconciles its provider directory.
Registry command derivation
The ACP registry specifies distribution types:
| Type | Command |
|---|---|
npx |
npx -y <package> ...args |
uvx |
uvx <package> ...args |
binary |
basename of the registry's cmd (e.g. ./bin/devin → devin) |
Binary entries use the executable basename so a PATH-installed binary is found directly, avoiding spawn ./bin/devin ENOENT.
Config
| Config | Default | Meaning |
|---|---|---|
emitReasoning |
true |
Whether agent_thought_chunk updates become reasoning-delta chunks. |
emitToolCalls |
true |
Inside a session, ACP-observed tool calls are always recorded as tool/call/tool/result session events (tool cards); without a session context (e.g. settings-page probes) they degrade to [tool: <title>] reasoning text. This option controls that fallback text only, never execution or recording. |
emitProgress |
false |
Whether extension progress notifications (e.g. _cognition.ai/output MCP connection lines) become reasoning text. Server chatter, so off by default. |
defaultModelId |
devin |
Fallback model id when ACP discovery returns no models. |
defaultModelName |
Devin (ACP) |
Fallback model display name. |
disposeEofGraceMs |
6000 |
Positive grace after stdin EOF before platform termination. |
disposeGraceMs |
3000 |
Positive POSIX grace after SIGTERM before SIGKILL. |
initTimeoutMs |
120000 |
Bound on the initialize handshake (plus any keyed authenticate round). |
sessionTimeoutMs |
60000 |
Bound on session/new, session/list, and session/set_config_option. |
authTimeoutMs |
15000 |
Bound on one authenticate round. |
Protocol contract
Each stream() call:
- Session acquisition: when the request carries a dsh
sessionIdand a reuse mapping exists (history not shrunken), the same ACP session is reused; otherwise a fresh ACPsession/newis created with the calling session's workspace ascwd(the connection's spawncwdwhen there is no session context). A shrunken history (compaction) also creates fresh. - Message sending: on a reused session only the new user messages are sent (already-sent history and assistant responses are skipped); on a fresh session the harness
messagesplussystemprompt are rendered into one ACP text block. - Sends
session/promptand streamsagent_message_chunkupdates astext-deltachunks. - When
emitReasoningis on,agent_thought_chunkupdates becomereasoning-deltachunks. - A
usage_updatenotification becomes ausagechunk (inputTokensis the server-reported context occupancy) and itssizeis remembered as the route'scontext.contextWindow. - The terminal
session/promptresponsestopReasonbecomes thefinishchunk.
Tool calls are never emitted as tool-call blocks. That is a requirement rather than an omission: the harness's agent loop hands tool-call blocks from the assistant message to executeToolCalls, but an ACP server has already run its own tools, so re-emitting them would make the harness execute them again against its own tool registry — double execution or an unknown-tool failure. Instead, ACP tool_call/tool_call_update notifications are written as tool/call + tool/result session events — the same pair executeToolCalls writes, tagged with the open step's turn/step — which the conversation UI renders as tool cards. The result cites its call via sourceEventSeqs; a failed status produces an isError result carrying an error identity, and a call still open when the prompt ends is closed with an empty result so the log never holds a dangling call. The card's row family follows the ACP tool_call.kind or Devin's _meta.inferenceToolName, mapped onto native tool names (read→read, edit→edit, execute/exec→bash, search→grep, fetch→web_fetch) so the call reuses the native icon, localized title, and openable file path; a call with neither identity keeps the server-provided title on the generic card. A bash call carrying only command also records the native description (the command's first line), so its settled result renders as a terminal card rather than generic JSON. A call without rawInput records {} as its arguments so the summary does not fall back to the opaque callId. Without a session context (model discovery, settings-page probes) the fallback is [tool: <title>] reasoning text, gated by emitToolCalls. The ACP server executes its own tools internally. A usage chunk is reported for conversation calls only: an auxiliary call (compaction, session-title) renders a purpose-built prompt whose occupancy would displace the real sample. session/request_permission follows the current session permission preset: danger-full-access allows automatically, while other presets use a one-shot harness approval request. Unavailable or failing approval and ACP requests without allow_once fail closed.
ACP plan updates map onto the harness task list: plan and the items form of plan_update are complete entry snapshots, normalized to {content,status} and written as one whole-list todo/write (plan_removed writes an empty list to clear it), rendered by the existing todos projection and task panel. ACP metadata such as priority is not persisted, and file/markdown plans are not mapped and leave the current list unchanged. A malformed entry drops the whole snapshot with a warning, and duplicate content keeps its first occurrence. Auxiliary calls and session-less probes write no task list.
One known card-ordering difference: ACP tool calls run during the model stream, so their session events carry smaller seqs than the assistant message that lands when the stream ends; the conversation UI orders by seq, so in the settled/replayed view the step's tool cards appear above its text — in native dsh tools run after the message lands and always sit below it, and the cards reflow from below to above once at the streaming-to-settled transition. This follows from the monotonic seq assignment and is not fixable plugin-side; it is accepted as-is.
Protocol dump
With DSH_LLM_ACP_DEBUG_DIR=<dir> set, every connection appends all of its ACP interactions as JSONL to acp-<command>-<timestamp>-<pid>.jsonl in that directory, for offline diagnosis of traffic and latency (for example the actual session/prompt payload, or where session/update-dropped entries come from). One event per line, each with its own arrival time and untruncated detail, without the in-memory buffer's collapsing; the file is created on the first event and its directory is created if absent. A write failure warns once and disables the dump without affecting the connection. With the variable unset no file is written.
Stop-reason mapping
| ACP | Harness finish |
|---|---|
end_turn |
stop |
max_tokens |
max-tokens |
refusal |
error (code REFUSAL) |
cancelled |
aborted |
max_turn_requests / unknown |
error |
Build
pnpm install
pnpm build # tsc -b && tsdown
Built artifacts are committed to the repository, so pnpm install alone is sufficient for consumers.
Known Limitations and Deferred Work
No harness tool ecosystem — the ACP server executes its own tools; harness
GenerateOptions.toolsis ignored.Context occupancy without output accounting — ACP's
usage_updatereports only the tokens currently in context (used) and the context window (size); it carries no response-token count. The adapter therefore emits ausagechunk withinputTokens = usedandoutputTokens = 0, and advertisescontext.contextWindowfromresolveModel, which lights up the harness's context-occupancy ring while cumulative output tokens stay at 0. The figure describes the ACP server's own context, not the harness-side prompt projection. How the window falls back:No capacity is advertised before the server reports a sample, so the first request's ring does not render (the harness will not invent a 0%); the next request picks it up.
A sample reported from
session/newcounts too, even when no prompt is consuming that session (the throwaway model-discovery probe).A later sample with an unusable window (not a positive integer) keeps the last known value instead of blanking an already-lit ring.
A later sample with a new usable window replaces it, so switching to a larger model shows up immediately.
Switching to another provider route that has not reported yet makes the harness clear the old capacity rather than reuse the previous server's window.
System prompt is in-band — ACP
session/newhas no system slot, so the harness system prompt is prepended to the user message text.Full-history render on fresh sessions only — only when no reuse mapping exists (first turn, one-shot calls) or the history shrank (compaction) does the adapter render the entire
messagesarray into one user message; reused turns send only the delta.ACP v1 (SDK 0.25.1) — the adapter uses
@agentclientprotocol/sdk0.25.1, whosesession/promptresponse carries the terminalstopReason(v1 contract).Extension protocol handling — Devin's
_cognition.ai/*notifications are consumed silently (_cognition.ai/outputprogress text is surfaced as reasoning whenemitProgressis on); other non-standard ACP extensions are swallowed to prevent SDK error logs.Lazy authentication — with no configured API key,
authenticateis not called up front: servers accepting env credentials or a cached login go straight tosession/new. Only a failedsession/newtriggers one bounded (authTimeoutMs)authenticateround plus a retry, so an interactive browser login only fires when genuinely required; the URL is surfaced via the warning log and theacp-auth-<id>settings route.Several login methods require a choice, never a guess — when a server advertises more than one method (codebuddy offers
iOA, reachable only from its intranet, alongside the publicexternal), the adapter no longer takes the first: with nothing selected it starts noauthenticateround, fails fast, and the conversation raises a picker fed by theacp-methods-<id>route. The pick is stored inservers.<id>.authMethodand can be changed later under Settings → ACP Servers. A single advertised method is used automatically, and a selection the server does not advertise counts as unselected (never a fallback to the first). Changing the selection rebuilds that server's connection, which applies the new method and abandons anauthenticateround still hung on the old one. The eager keyed round resolves through the same rule, so a configured API key no longer picks a method on your behalf.Subagent activity is detectable and can be mapped from permissions, but not intercepted — a server spawns its subagents inside its own process, where the harness has no hook (ACP is server-initiated and the adapter never forwards tool inputs/outputs). What it can do is detect and report, keyed on a tool call's
_metarather than its human-readable title:Fact Structure Value Subagent spawned tool_call._meta["cognition.ai/inferenceToolName"]run_subagentProfile / short label that call's rawInputprofile/title(kindis unset, so it cannot classify)Calls made inside it those calls' _meta["cognition.ai/subagent_context"]parentAgentIdThe subagent finished a tool_call_updatewith no matchingtool_call, whosetoolCallIdis thatparentAgentIdstatus: completedWhether it is surfaced is decided by
servers.<id>.subagentMap(dsh permission preset name or sandbox mode →notice/silent,noticeby default): a supervised session is told a fan-out happened while a fully delegated one stays quiet — a subagent bills as its own session with its own context window, which is the only thing worth interrupting for. It is a notice only (rendered on the reasoning channel and gated bysubagentMapalone), never an approval, and it cannot stop the server from spawning more.
License
MIT
Comments
Comments live in GitHub Discussions. Sign in with GitHub to post or react.