Define the in-file RFC contract in docs/rfc/README.md § The file format: the header block (`# RFC: <title>` plus a dateless Status enum cross-checked against the lifecycle folder), the per-lifecycle body skeleton (a Problem opener everywhere; Proposal/Alternatives considered/ Acceptance criteria/Risks in proposed/; present-tense Decision/ Consequences with proposal-era headings banned in implemented/; the frozen proposal shape in rejected/), and a mandatory Alternatives considered section with a date-fenced grandfather comment for pre-format RFCs whose alternatives are not reconstructible from the record. Enforce it with a new doc-sync gate, scripts/verify-rfc-format.ts, and normalize all 112 RFCs to it: ~15 Status-line spellings collapse to the enum, 29 Context openers become Problem, the 39 legacy-format XXX debt markers are resolved and banned from reappearing, proposal-era sections in implemented RFCs are rewritten to shipped reality (including the web/fs/subagent seam RFCs' migration plans and test checklists, closing the doc-tiers deferred-work item on the web seam), every RFC gains an Alternatives considered section or the grandfather comment, and the bilingual pair is re-mirrored and re-recorded. Move the generated index tables out of README.md into a fully generated docs/rfc/INDEX.md — gen-rfc-index now writes the whole file, and verify-rfc-classification checks its freshness and rejects index-shaped rows in the curated README — which makes room for the format contract to live in the README front door instead of a separate FORMAT.md. The decision record, and the first RFC written in the new format, is docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md.
21 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.
Alternatives considered
- A process-wide stdout hijack inside
dsh-acp(defensively monkey-patchingconsole.log/process.stdout.write) — rejected: it lives outside Cordis' effect-scoped, HMR-friendly plugin model, races the connection's own stdout handoff, and fights the logger. The stdout guarantee is config-only. - Injecting
agentLoopdirectly instead of the abstract create/resume factory — the recorded fallback, taken only if the factory seam is judged not worth it, with the architecture-rule exception recorded indocs/architecture.md.
Acceptance criteria
- The
acp-agentexample speaks ACP over stdio end-to-end:initialize,session/newwith a validated absolutecwdhonored as the session workspace, streamedsession/updateframes per prompt turn,session/loadre-deriving identical history, andsession/promptresolving with the correct wirestopReason. - stdout carries only framed JSON-RPC (asserted by test); the permission gate settles every
session/request_permissionexactly once — on outcome, cancel, or connection close. - The plan's test set runs green: the property-based protocol invariants, the codec unit tests over an in-memory duplex pair, the HMR-safety test, the failure-path matrix, and the self-skipping real-API e2e that verifies the world.
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.