- 009: the crash-tail "overwrite" contradicted the append-only contract. Name it explicitly as a one-time truncation-repair (ftruncate+fsync to the last complete turn/end byte offset) that removes only the never-committed crash tail; committed events are never rewritten. Qualify the append/impl/ADR wording to match. - 010: remove the remaining concrete-loop references — the session/new and session/load table rows now point at the dsh-agent create/resume factory, and the Risks disposal line uses the interface-level settle signal (agent/status) instead of LoopAgent-only agent.done.
17 KiB
RFC 010: Agent Client Protocol (ACP) support — drive the coding agent from external editors
Status: proposed
Problem
The coding agent is reachable only through the readline stdio-chat plugin: it reads lines from stdin, calls agent.send(), and prints agent/stream-chunk to stdout. There is no structured protocol, so the agent cannot be embedded in an editor — no streaming render, no tool-call display, no permission UI, no resumable sessions.
Editors are converging on the Agent Client Protocol (ACP), which Zed and others speak: JSON-RPC 2.0 over newline-delimited stdio, modeled on the Language Server Protocol. An editor boots the agent as a subprocess and exchanges initialize / session/new / session/prompt, rendering streamed session/update notifications and session/request_permission prompts. The goal is for the agent to be a drop-in ACP server — implement the protocol once and run in any ACP client, with no per-editor glue.
This RFC has a hard prerequisite on RFC 009: it assumes durable session persistence (the SessionPersistence service and the async AgentLoop.resume seam) is implemented, so resuming a session via session/load is in scope. None of those APIs exist yet — AgentLoop currently exposes only the synchronous create — so 010 must land after, or in the same change as, 009, and pins to 009's resume(agentId, resumeSessionId) contract. RFC 009 persists every SessionEvent verbatim (including assistant/chunk), so a loaded session has the stream chunks needed to replay turns to the client.
Proposal
A new plugin package @deepseek-ai/dsh-acp — a client-driver / UI plugin, the structured analogue of stdio-chat. It is NOT a change to the loop and NOT an ADR 0009 interface/implementation/consumer capability split; it consumes the existing agent/* event taxonomy and the tools/execute waterfall.
It depends on the official @agentclientprotocol/sdk (the AgentSideConnection class) — Apache-2.0, actively versioned. The SDK declares a zod peer dependency and imports zod/v4 at runtime, so packages/acp must declare zod itself (per the workspace dependency constraints). This is the renamed successor to @zed-industries/agent-client-protocol, which is now deprecated on npm.
The mapping between ACP and existing harness seams — each row names the seam and any required extension:
| ACP (client ⇄ agent) | Harness seam | Notes |
|---|---|---|
initialize |
static handler | negotiate protocolVersion (echo the supported version, else error); advertise text-only promptCapabilities and loadSession: true; report agent name/version |
session/new {cwd, mcpServers, additionalDirectories} → {sessionId} |
the dsh-agent create factory (see Dependency note + Plan) |
the seam must accept { sessionId, meta } so the ACP-generated sessionId becomes the live/persisted session id and the validated cwd is attached as the SessionHeader (today AgentLoop.create(id) hardcodes ${id}-session and takes no metadata); reject a 2nd session (single-session MVP, see RFC 011); cwd validated (require absolute) with "launch the server in the workspace root" documented until the workdir seam exists; mcpServers ignored (no mcpCapabilities advertised); non-empty additionalDirectories rejected for the MVP (the bridge cannot yet widen bash/tool filesystem scope, so silently ignoring them would desync the client's filesystem-scope UI) |
session/load {sessionId, cwd, mcpServers, additionalDirectories} |
the dsh-agent resume factory (RFC 009 + Dependency note) |
load { meta, events }, seed the session, re-derive history via deriveMessages(), replay prior turns to the client as session/update per the ACP load contract; additionalDirectories rejected as in session/new |
session/prompt {prompt} |
agent.send() (idle) |
text blocks → TextBlock; reject image/audio per advertised capabilities; one in-flight prompt per session |
resolve session/prompt → {stopReason} |
agent/turn-end (extended, see Plan) |
map the harness kebab TurnEndReason to the ACP snake_case StopReason wire enum: completed→end_turn, max-tokens→max_tokens, aborted(cancel)→cancelled, plus refusal/max_turn_requests when applicable; honor the batch-into-one-turn and send-not-synchronously-running settle semantics |
session/update: agent_message_chunk |
agent/stream-chunk text-delta only |
do NOT also emit on block-end(TextBlock) — it carries the fully-assembled block and would duplicate the streamed text |
session/update: agent_thought_chunk |
agent/stream-chunk reasoning-delta |
|
session/update: tool_call (pending→in_progress) |
session/event tool/call |
demux via a Session→sessionId map; kind inferred from the tool name |
session/update: tool_call_update (completed/failed) |
session/event tool/result |
a throwing tools/execute yields NO tool/result → fail the pending tool UI from agent/error/turn-end |
session/request_permission {sessionId, toolCall, options} |
prepended tools/execute listener |
no-op unless exec.agent is ACP-owned; await the outcome; selected/allow_* → next(); reject_*/cancelled → veto ToolExecutionResult{isError} |
session/cancel (notification) |
agent.abort(reason) |
settle the in-flight prompt as cancelled; resolve any pending permission as cancelled exactly once |
The permission gate is the first real consumer of the tools/execute veto seam (the documented "single veto/sandbox/permission seam" plus the deferred "Permission system" TODO in docs/architecture.md). It is a single global listener registered with prepend: true so it runs before any other tool wrapper. ToolExecution.agent is optional and the Agent interface carries no origin marker, so the bridge tracks ownership itself: it records each agent it creates in a WeakMap<Agent, sessionId> and the gate no-ops (calls next() immediately) for any exec.agent it does not own — non-ACP agents and the no-agent case pass straight through. For an owned agent it resolves the session, issues session/request_permission, and stores the pending resolver on that session's record so the outcome — or a session/cancel/connection-close — settles it exactly once.
Lifecycle and disposal: the connection, listeners, and in-flight permission promises register via ctx.effect/ctx.on; teardown is async and awaits quiescence — close the connection, settle/reject pending permissions, agent.abort(), and wait for the agent to settle. The disposal-settle signal must come from the dsh-agent interface, not the loop: agent.done exists only on the concrete LoopAgent, so the bridge instead observes agent/status reaching idle/disposed (or the RFC lifts a quiescence promise onto the Agent interface). Every listener contains its send() exceptions (log, never reject the turn) because stream chunks are emitted inside the model step, so a throwing listener would corrupt the turn.
Dependency note (architecture rule). docs/architecture.md states "plugins depend on interface packages, never on dsh-agent-loop." Creating and resuming agents is currently only on the concrete AgentLoop (ctx.agentLoop), so this RFC proposes adding an abstract create/resume factory to the dsh-agent interface (registry-level create({ sessionId, meta }) / resume(...)), implemented by the loop, so dsh-acp injects only agents (the interface) and the dependency rule holds. The alternative — injecting the concrete agentLoop and recording a documented exception in the architecture doc — is explicitly the non-preferred fallback.
Plan
- Package scaffold
packages/acp/per the cookbook; add@agentclientprotocol/sdkandzod. Add the abstract create/resume factory todsh-agent(the interface) so the bridge caninject: ['agents', 'sessions', 'tools', 'sessionPersistence']without depending on the concrete loop;sessionPersistenceis required becausesession/loadadvertisesloadSession: true. (Fallback only if the factory is judged not worth it: injectagentLoopdirectly and record the architecture-rule exception indocs/architecture.md.) - Connection plus
initialize/session/new: wireAgentSideConnectionto stdin/stdout; protocolVersion negotiation; the single-session guard; create the live session through the new{ sessionId, meta }factory seam (so the ACPsessionIdand validatedcwdbecome the session's id and header); thesessionId↔agentandSession↔sessionIdmaps. - Internal edit — turn-end reason fidelity (sanctioned: edit internals to fit ACP). Extend
TurnEndReasonMapin the proper places: (a) declaration-merge amax-tokensvariant in the owning package (packages/session/src/types.ts, alongsidecompleted|aborted|error|disposed) — addmax-tokensbecauseFinishReasonMapproduces it (DeepSeek mapslength→max-tokens); do not addrefusal, since no current adapter produces it (unknown DeepSeek finish reasons collapse toerror), but leave a comment inTurnEndReasonMapnotingrefusalshould be added when an adapter first emits it (FinishReasonMapis merge-extensible); (b) makeagent-loop'sloop.tspopulate the reason from the modelfinishchunk —assembler.finishlives insiderunStep, sorunStepmust return it up torunTurn, and the rule is "the last step's finish reason wins, but anymax-tokensin the turn surfaces asmax-tokens"; (c) no consumer exhaustively switches overTurnEndReasontoday (the invariants plugin switches onSessionEventType, andderiveMessagesignoresturn/end), so addingmax-tokensis a non-breaking extension — but recheck before landing; (d) update docs/architecture.md (the CI-verified loop-lifecycle/event-taxonomy doc) and the affected package READMEs/JSDoc (dsh-session,dsh-agent,dsh-agent-loop) per the repo doc-sync policy. This replaces a fragile "observe the finish chunk in the bridge" hack with a real, documented contract. - Prompt-turn streaming plus load: translate
agent/stream-chunkandsession/eventintosession/update; resolvesession/prompton settle, mapping the harnessTurnEndReasonto the ACPStopReasonwire enum (completed→end_turn,max-tokens→max_tokens,aborted→cancelled) — a small total function with a test asserting the exact wire strings, since the SDK rejects an unknownstopReason. Concrete correlation, since the loop batches queued messages into one turn andsend()does not synchronously flip to running: install listeners beforesend(); gate on an observedagent/turn-start(confirms work was accepted) then resolve on the nextagent/turn-end; reject an empty/whitespace prompt up front rather than callingsend()(no turn would ever start, so the RPC would hang). Implementsession/loadon RFC 009's resume seam. - Permission gate: a single
tools/executelistener registered withprepend: true, owning aWeakMap<Agent, sessionId>of bridge-created agents; no-op (next()) for unowned/no-agent calls; for owned calls →session/request_permission→ allow (next()) / veto; settle the stored resolver exactly once on outcome, cancel, or connection close. - Example wiring (extract a shared base).
@cordisjs/plugin-includeis itself a plugin entry that resetsctx.baseUrland loads a path, so a childcordis.ymlcan nest-include a shared base; the extraction is safe because every dependent plugin declaresinject(loader groups initialize viaPromise.all, so YAML order is NOT the dependency mechanism — never rely on it). Extract the provider/tool core (llm, sessions, system-prompt, tools, agents, invariants, llm-deepseek, bash-local, tool-bash) intoexamples/base.yml; have bothcoding-agentand a newexamples/acp-agent/include it and add their own UI plugin plus logger. Keepagent-loopper-example (NOT in the base):AgentLoopcreates its configured agents in its constructor, and the two examples disagree —coding-agentneeds a pre-createdmain(itsstdio-chatcallsctx.agents.get('main')), whileacp-agentmust pre-create none (ACPsession/newcreates agents). Socoding-agentdeclaresagent-loopwithagents: [{ id: main, … }]andacp-agentwithagents: [].acp-agentloadsdsh-session-persistence-jsonl(from RFC 009 — required forsession/load), omits the stdout logger (see Risks), and addsyarn demo:acpplus the Zedagent_serverssnippet. - Tests (the repo cares a lot here): a property-based test for the protocol shape (precedent: RFC 001 / ADR 0013) — fuzz arbitrary harness event sequences and assert ACP-stream invariants (never a
tool_call_updatebefore itstool_call; exactly onesession/promptresolution per prompt; monotonic, well-formed ordering;stopReasonin the legal set); codec unit tests over an in-memoryDuplexpair (driveAgentSideConnectionwithout a subprocess; assert exact frames forinitialize,session/new, a full prompt turn); the mandatory HMR-safety test (dispose the fiber; assert the connection closed, allctx.onlisteners gone, any in-flightrequest_permissionsettled); failure-path tests (connection closes mid-stream; closes with a permission pending; a notificationsend()rejects but the turn survives;finish{kind:'error'|'aborted'}; atools/executethrow with notool/result; a secondsession/newrejected; asession/promptwhile one is in flight; an empty prompt rejected without hanging; asession/loadre-derives identical history and replays it); and an e2e (*.e2e.ts, self-skips withoutDEEPSEEK_API_KEY) that bootsexamples/acp-agent, connects aClientSideConnection, sends a real prompt, owns and disposes the harness inafterEach, and verifies the world (files on disk), not the agent's self-report. - Docs: module/JSDoc plus a package README; extend the extension cookbook with the client-driver pattern. Flip Status to
implementedon landing; write an ADR only if a decision proves durable, contested, and surprising (candidates: thetools/executepermission-ownership rule, the npm-dependency choice) — not auto-required.
Deferred (each names its owning future work):
- Multiplexing concurrent sessions → RFC 011.
cwdhonoring. There is no current path fromsession/new.cwdto the bash workdir (AgentLoop.createtakes onlyAgentOptions;tool-bashforwards only an explicitargs.workdir;LocalBashExecutor.resolvedefaults to its own config orprocess.cwd()). The MVP validatescwd(require absolute) and requires the server to be launched in the workspace root, erroring on a mismatch rather than silently running tools in the wrong directory; honoring an arbitrarycwdlater means extending the agent-creation seam to carry a workdir.- Client
terminal/*proxying (a live editor terminal) andfs/*(editor-rendered diffs) — a futureBashExecutorover the ADR 0009 bash seam, gated onclientCapabilities.terminal. - Image/audio prompts (blocked on the DeepSeek adapter, which skips
imageblocks today), modes, auth,available_commands/slash-commands,plan, andusage_update.
Risks
stdout is the protocol — guaranteed by config, not by monkey-patching. The console logger writes through console.log to stdout, so any stdout UI/logger plugin corrupts JSON-RPC. The guarantee is config-only: the acp-agent example loads no stdout plugin (no console logger, no stdio-chat) and, if logging is wanted, uses a stderr exporter. A defensive process-wide process.stdout.write/console.log hijack inside dsh-acp is explicitly rejected — it lives outside Cordis' effect-scoped, HMR-friendly plugin model, races the connection's own stdout handoff, and fights the logger. A test asserts the example emits only framed JSON-RPC on stdout.
New third-party runtime dependency plus protocol drift: @agentclientprotocol/sdk is young (0.25.x, recently renamed) and evolving. Pin the version and isolate churn to the one bridge package. This is not a vendoring-policy violation — ADR 0001 vendors the framework; genuine third-party deps already live on npm (@earendil-works/pi-ai).
Turn-settle and prompt-correlation hazards: honor "queued messages batch into one turn" and "send() does not synchronously flip to running" (see stdio-chat.ts and the defensive-patterns section of docs/architecture.md); gate resolution on an observed running→idle transition and handle the empty-prompt / no-work branch so an RPC can't hang.
Permission-await and disposal hangs: a pending request_permission whose connection closes or whose turn aborts must settle exactly once; disposal must reach quiescence (observe the interface-level settle signal — agent/status reaching idle/disposed, since agent.done is LoopAgent-only), not orphan awaits on a closed pipe.
The 100% per-file coverage gate (repo policy) makes a branch-heavy protocol bridge real work. Accepted deliberately, surfaced so it isn't a surprise at PR time.
ACP protocol-shape details (exact method names, session/update variants, permission option kinds, stop reasons) are taken from the ACP spec and the @agentclientprotocol/sdk types; they are not independently verifiable until the dependency is added, so the implementation pins the SDK version and conforms to its types rather than to this RFC's prose where they differ.