@deepseek-ai/dsh-acp
Agent Client Protocol bridge over JSON-RPC stdio. Editors can create or resume agents, stream their events, answer questions and approvals, and render tool calls. One connection supports multiple isolated sessions; Zed is the primary compatibility target.
It is a client-driver / UI plugin, the structured analogue of the readline stdio-chat plugin — NOT a loop change and NOT a capability seam. It consumes the existing agent/* event taxonomy, the dsh-agent create/resume factory, and dsh-session-persistence.
Service / plugin
apply(ctx, config) — wires an AgentSideConnection (from @agentclientprotocol/sdk) to process.stdin/process.stdout and implements the ACP Agent method surface.
The plugin injects agents, sessions, sessionPersistence, tools, and userInteraction, never the concrete loop. Persistence backs session/load; tool definitions own presentation; user interaction maps agent questions to ACP forms.
Config
| Key | Default | Meaning |
|---|---|---|
model |
— | Model name for created agents (must have a registered adapter). |
(No persona key: dsh-system-prompt's own persona config supplies the global default section, so ACP-created agents render it without the bridge carrying prompt text. An agent-scoped same-name section may still shadow that default.)
The initialize handshake reports a fixed server identity (agentInfo: { name: 'deepseek-harness-acp', version: '0.0.1' }) — branding is a literal at the initialize site, not config.
ACP method mapping
| ACP method | Harness seam | Notes |
|---|---|---|
initialize |
static | negotiate protocolVersion; advertise baseline prompt capabilities (text, plus resource_link rendered as text) and loadSession: true |
session/new |
ctx.agents.create({ sessionId, meta:{cwd} }) |
creates a new session/agent; N concurrent sessions are allowed, keyed by id; cwd must be absolute (it becomes the session's workspace — see Per-session cwd); non-empty additionalDirectories and mcpServers rejected |
session/load |
ctx.agents.resume(...) |
reserves the id, verifies the persisted cwd, resumes, and replays user, assistant, and tool events |
session/prompt |
agent.send() |
supports ACP text and resource_link blocks; rejects image/audio/embedded resource and empty prompts; one in-flight prompt PER session (independent); settles on the OWNING turn's end (a turn that ends in error rejects the RPC) |
session/cancel |
agent.cancel() |
the queue-aware cancel: aborts a running step, clears queued + steering work, and drops a turn about to start, then settles the prompt cancelled — for ONLY that session (a cancel never touches another session's stream or prompt) |
session/update |
session/event |
streams user replay, assistant text/reasoning, and tool render intents |
elicitation/create |
ctx.userInteraction.ask() |
maps ask_user_question questions to ACP form elicitations; option descriptions are shown in enum titles, multi_select uses ACP array enums, optionless requests use a required custom field, and a non-empty custom answer overrides any selected choice |
session/request_permission |
approval/request listener |
answers one-shot requests for bridge-owned calls and delegates others |
session/set_config_option |
setSandboxMode / setApprovalPolicy |
per-session knob switching over session config options — see "Session config options" |
Multi-session
Forward and reverse indexes route every event, prompt, cancel, and approval to one session. Each session permits one in-flight prompt; teardown drains all sessions in parallel. See the multi-session RFC.
Session config options
The bridge advertises sandbox-mode and approval-policy only when their services are composed. Current values fold from each session's log over the composition default, so load restores overrides directly. session/set_config_option validates against the closed vocabulary, calls the domain writer, and returns refreshed state. Changes inside an open turn append immediately; idle changes are coalesced in memory and anchored at the next agent/prompt-submit, preserving turn enclosure and event order. A crash before anchoring discards the pending change, and load reports durable log truth. See the sandbox RFC.
Background bash tasks use the session id as an opaque owner token, so one session cannot inspect or stop another's task. That contract belongs to dsh-tool-bash.
Per-session cwd
session/new records the request's absolute cwd in the session header. session/load requires an absolute request cwd matching persisted metadata and rejects missing or mismatched metadata before constructing an agent. Bash defaults to that workspace; an explicit relative workdir resolves against it. additionalDirectories remains unsupported.
Tool-call presentation
Tools return provider-neutral generic, terminal, or diff render intents from presentCall() and presentResult(). The bridge maps the discriminator to ACP without special-casing tool names and falls back to a generic card. Per-session call-id state supplies result events with their omitted name and arguments during live streaming and replay. See dsh-tools.
Terminal card (capability-gated)
When the client advertises _meta.terminal_output, terminal intents map to Zed's terminal info, output, and exit metadata; result text is omitted because ACP updates replace call content. Other clients receive a generic card and fenced console fallback. Session creation snapshots the capability so call and result agree. The command still executes through the harness, not ACP terminal creation. See the terminal-rendering RFC.
Settle-exactly-once
A prompt captures its owning turn and settles exactly once from the matching durable turn/end, even if presentation failed. Turn correlation excludes stale endings. Error turns reject with an ACP internal error; empty prompts reject before enqueue.
Permission prompts
For a bridge-owned call, the approval seam maps ask to an editor prompt with one-shot allow/reject options. Foreign or call-less requests delegate; unknown choices never grant, cancellation stays cancellation, and transport failure becomes fail-closed unavailability. Whether a tool asks remains policy outside the bridge.
Disposal & disconnect
Disposal and client disconnect share one memoized teardown. It cancels pending prompts and disposes all owned agent handles in parallel, waiting for loop exit and final flush before registry removal. Mid-turn teardown records disposed; session/cancel records aborted.
Known limitations (tracked TODOs)
additionalDirectories— rejected. A session operates in its singlecwd(see Per-session cwd); widening the tool/filesystem scope to extra roots is a separate sandbox concern, not yet implemented.
stdout is the protocol
The JSON-RPC frames go on stdout, so this plugin MUST run in an example that loads no stdout logger (the console logger writes to stdout and would corrupt the frames). The guarantee is config-only — see examples/acp-agent (no console logger) and ACP support risks. A stderr exporter is fine for logging.
Running
pnpm --dir /path/to/deepseek-harness run demo:acp boots examples/acp-agent (needs DEEPSEEK_API_KEY). Point an ACP client at it; for Zed, add to agent_servers:
{
"agent_servers": {
"DeepSeek Harness": {
"command": "pnpm",
"args": ["--dir", "/path/to/deepseek-harness", "run", "demo:acp"]
}
}
}