Collapse docs/adr/ and docs/rfc/ into a single docs/rfc/ with proposed/, implemented/, and rejected/ subfolders. Every file is renamed to yyyy-mm-dd-topic-title.md, where the date is when the topic was first proposed (from git history). ADRs and RFCs that covered exactly the same topic are merged (property-based testing, session persistence); the umbrella RFC 005 stays split across its three implemented decisions, and RFC 006's deferred part-3 (API extractor reports) splits into its own proposed RFC. All cross-references become machine-checkable relative links instead of bare "ADR NNNN" / "RFC NNN" prose. Add a verify-md-links doc-sync gate (scripts/verify-md-links.ts) that checks every relative Markdown cross-link resolves, wired into doc-sync alongside verify-md-wrap. This makes the reorganization self-verifying: the same change that rewrote ~forty inter-doc links adds the check that proves none dangle. Document the cross-link convention in a new docs/AGENTS.md and record the gate as an implemented RFC. doc-sync, typecheck, lint, and the full test suite (667) all pass.
4.5 KiB
RFC: Multiplex concurrent ACP sessions over one connection
Status: proposed
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.
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 ownLoopAgent. - 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/cancelaborts only its own session's agent and settles only that session's in-flight prompt.agent.abort()drives a per-agentAbortController, so the per-sessionexec.signalis the natural isolation fence. - 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.
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.