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.
7.0 KiB
RFC: Multiplex concurrent ACP sessions over one connection
Status: proposed
Implementation status: the multi-session bridge (steps 1, 3, 4) and the bash task-ownership isolation are implemented in
packages/ui/acp+packages/bash/tool-bash. Per-session permission ownership is deferred — it depends on the ACP support permission gate (TODO(rfc010-permission-gate)), which is itself deferred; theagent→sessionIdreverse map the gate will route through is in place. Step 2's per-session disposer scope is now implemented (see agent lifecycle & ownership seams): the factory returns a per-agentAgentHandlewhosedispose()stops the loop, awaits quiescence, unregisters the agent, and removes its session, so a bare client disconnect leaves no registered agent or session-store entry. Status staysproposeduntil per-session permission ownership lands.
Target-client note: Zed is the current target ACP client, and its ACP client maintains a
HashMap<SessionId, AcpSession>pluspending_sessionsfor concurrentsession/loadcalls. The competing simplification to return to one live session per connection was rejected after checking that target-client shape; this RFC remains the path for finishing multiplexing and per-session permission ownership. See the rejected simplification.
Problem
ACP support ships with a single active session per connection: a second session/new is rejected. Editors expect to run several conversations over one agent subprocess — a user opens multiple threads, or a client pre-warms sessions. The single-session guard is a deliberate MVP scope cut, not an architectural limit; this RFC lifts it.
This paragraph is historical: the multi-session bridge has landed. The remaining proposed work is per-session permission ownership plus the lifecycle seams now tracked in agent lifecycle and ownership seams.
Proposal
The harness core already supports many agents (AgentRegistry.list() and AgentLoop.create impose no count limit), so multiplexing is a bridge-layer change in @deepseek-ai/dsh-acp, not a loop or core change.
- Lift the single-session guard in
session/new; allow N live sessions, each mapped to its ownReactLoopAgent. - The bridge's
sessionId→agentandSession→sessionIdmaps (introduced single-entry by the ACP support RFC) become true multi-entry, plus a thirdagent→sessionIdreverse map: thetools/executepermission gate receives onlyexec.agent(no sessionId), so it needs an O(1) reverse lookup to find the owning session. Everyagent/*event and everysession/eventis demuxed strictly by id, so two sessions streaming at once never interleave theirsession/updatenotifications. - Per-session prompt queues: the ACP support RFC's single-entry in-flight-prompt state becomes multi-entry — one in-flight prompt per session, tracked per
sessionId. - Per-session cancel routing:
session/cancelcancels only its own session's agent (via the queue-awareagent.cancel()) and settles only that session's in-flight prompt. The cancel is scoped to that one agent — a per-agentAbortControllerfor the running step plus the agent's own queued/steering FIFOs — so it never touches another session's stream or pending prompt. - Per-session permission ownership: a
session/request_permissionand its outcome are bound to the originating session via the reverse map, so a permission prompt or a cancel in one session can never resolve another session's pending permission.
Plan
- Generalize the two id maps to multi-entry and add the
agent→sessionIdreverse map; add a per-session record holding the agent, the in-flight-prompt state, the pending-permission registry, and the session's disposer scope (see step 2). - Give each session a real per-session disposer scope, NOT
ctx.extend()— in Cordisctx.extend()only creates a child context/prototype, butctx.on()registered on it is still owned by the current plugin fiber, so disposing it would not remove that session's listeners. Use a genuine child fiber (load a per-session sub-plugin, e.g.ctx.plugin(...)returning a fork, or collect each session'sctx.ondisposers in its session record and call them on teardown). Demux everyagent/*andsession/eventby id into the right session record. Note the single globaltools/executelistener stays on the bridge root (it must see all agents) and routes via the reverse map. - Lift the
session/newguard; keepsession/load(from ACP support) working per session. - Tests for cross-session isolation: two sessions streaming and permission-prompting concurrently never interleave; a cancel/abort in one session leaves the other's stream and pending permission untouched; per-session in-flight-prompt enforcement holds independently; disposing one session leaves the others running.
Alternatives considered
A per-session ctx.extend() scope — rejected: in Cordis, ctx.extend() only creates a child context/prototype, and ctx.on() registered on it is still owned by the current plugin fiber, so disposing it would not remove that session's listeners. A genuine child fiber (or a per-session collection of disposers) is required.
Acceptance criteria
- N concurrent sessions stream and permission-prompt without interleaving their
session/updatenotifications; a cancel in one session leaves every other session's stream, queued prompts, and pending permissions untouched. - Disposing one session removes exactly its own listeners; connection teardown reaches quiescence across all sessions.
- One session's agent cannot read or kill another session's background bash task.
Risks
Listener fan-out cost: each session adds listeners; ensure disposal of one session removes exactly its own and the connection teardown (from ACP support) still reaches quiescence across all sessions.
The subtle correctness trap is cross-session leakage — a cancel or abort on one session settling another session's pending permission. The per-session permission ownership rule (routed via the agent→sessionId reverse map) and its isolation test are the guard.
Shared background-task state: the bash executor's task ids are global and predictable (bash-1, bash-2, …), and bash_output/bash_kill look up by id without checking the caller. Under one session this is benign; under N sessions one session's agent could read or kill another's background task. This is a pre-existing tool-bash gap that multi-session turns into a real isolation hole — fixing it (validate the caller against the task owner) belongs with this RFC or a companion tool-bash change.