The loop recorded every model token delta as a durable `assistant/chunk` session event AND emitted an identical live `agent/stream-chunk` Cordis event one line later. Same StreamChunk, same turn/step; the emit added only the live Agent handle, which the sole consumer discarded. This is the boundary-mirror duplication the event-domain work removed for turn/step boundaries, applied to the token stream — a follow-up the boundary RFC explicitly deferred. The premise is settled: chunk persistence is authoritative (the proposal to stop persisting chunks was rejected — replay/snapshots depend on it), so `assistant/chunk` on `session/event` is the load-bearing token stream and `agent/stream-chunk` is pure redundancy. - Remove the `agent/stream-chunk` declaration + emit; drop the now-unused StreamChunk import from dsh-agent's types. - Migrate `dsh-ui-stdio` (the only live consumer; ACP already reads assistant/chunk off session/event) to render assistant/chunk in its existing session/event listener. Consolidating to one listener also makes the inReasoning dim-SGR flag deterministic across chunk/boundary events (they no longer race across two listeners). - Repoint the agent-loop tests (cancel/loop) and ui-stdio tests to the session/event assistant/chunk feed. - New RFC (implemented/simplification/2026-07-02-remove-stream-chunk-mirror); amend the boundary RFC's retained-list entry to cross-link; update architecture, cookbook, event-domain-semantics, the ACP proposal, and the regenerated cordis catalog. Snapshot goldens unchanged (ACP never used the mirror), confirming no editor-facing transcript change.
20 KiB
RFC: Agent Client Protocol (ACP) support — drive the coding agent from external editors
Status: proposed
Implementation status (MVP landed): steps 1, 2, 3, 4, 6, 7, 8 are implemented in
packages/ui/acp+examples/acp-agent. Step 5 (thesession/request_permissionpermission gate) is deferred — the bridge ships a pass-through (tools run with the executor's full authority) markedTODO(rfc010-permission-gate), and lays down only theWeakMap<Agent, sessionId>ownership seam the gate will build on. Status staysproposeduntil the gate lands.session/cancelis the queue-awareagent.cancel(): it aborts a running step, clears queued + steering work, and drops a turn that is about to start, so a queued-but-not-yet-started prompt never runs and a later prompt cannot be batched into the cancelled turn. Per-sessioncwdis now honored (lifting the original "launch the server in the workspace root" restriction — see § Deferred):session/newaccepts any absolutecwd, andsession/loadrequires the requestcwdto match the persisted sessioncwdso the editor and bash executor agree on the workspace.
Problem
The coding agent is reachable only through the readline stdio-chat plugin: it reads lines from stdin, calls agent.send(), and prints the assistant token stream (session/event assistant/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 session persistence: 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 ACP must land after, or in the same change as, session persistence, and pins to its resume(agentId, resumeSessionId) contract. Session persistence 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 capability seams interface/implementation/consumer capability split; it consumes the existing agent/* event taxonomy and the tools/pre-execute/tools/post-execute waterfalls.
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/ui/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 ACP multi-session); cwd validated (require absolute) — any absolute cwd is honored: it becomes the session's SessionHeader.cwd and the default bash workdir (per-session cwd, see § Deferred → RESOLVED), so the server need not launch in the workspace; non-empty mcpServers and additionalDirectories are rejected for the MVP because silently ignoring requested servers/roots would desync the client's tool and filesystem-scope UI |
session/load {sessionId, cwd, mcpServers, additionalDirectories} |
the dsh-agent resume factory (session persistence + 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; mcpServers and 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} |
the turn/end session/event (its reason) |
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 |
session/event assistant/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 |
session/event assistant/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.cancel(reason) |
the queue-aware cancel (abort running step, clear queued + steering, drop an about-to-start turn); 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 must reach quiescence, not just request it — close the connection, settle/reject pending permissions, and dispose each owned agent through its AgentHandle.dispose() (which stops the loop, awaits its exit, and unregisters). Owner teardown goes through that handle seam, not the loop's concrete agent.done (which exists only on ReactLoopAgent); a non-owner that merely wants to observe the current work settling without tearing the agent down awaits the interface-level agent.whenIdle(). 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/ui/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/core/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
session/event(theassistant/chunktoken stream plus boundaries and tool activity) intosession/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 thesession/eventlistener beforesend(); capture the prompt's owning turn from itsturn/startrecord, then resolve on that turn'sturn/end(withagent/statusidle/disposed as a fallback); reject an empty/whitespace prompt up front rather than callingsend()(no turn would ever start, so the RPC would hang). Implementsession/loadon the session-persistence 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 session persistence — required forsession/load), omits the stdout logger (see Risks), and addspnpm run demo:acpplus the Zedagent_serverssnippet. - Tests (the repo cares a lot here): a property-based test for the protocol shape (precedent: property-based testing) — 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; record a decision in this RFC only if it 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 → ACP multi-session.
RESOLVED. Originally there was no path fromcwdhonoring.session/new.cwdto the bash workdir (tool-bashforwarded only an explicitargs.workdir;LocalBashExecutor.resolvedefaulted to its own config orprocess.cwd()), so the MVP validatedcwd(require absolute) AND required the server to launch in the workspace root, erroring on a mismatch. This is now lifted: the validatedcwdis stored asSessionHeader.cwd, anddsh-tool-bashdefaults the bash workdir to the calling agent'ssession.header.cwd(an explicit modelworkdirstill wins; a relative one resolves against it). Any absolutecwdis honored — the server need not launch in the workspace, and N sessions can each target a different directory. Widening scope beyond the single cwd (additionalDirectories) remains deferred.- Client
terminal/*proxying (a live editor terminal) andfs/*(editor-rendered diffs) — a futureBashExecutorover the capability seams 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 — vendoring Cordis as source 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 — tear each owned agent down through AgentHandle.dispose() (which stops the loop and awaits its exit), rather than orphaning 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.