Multi-subscriber session-sharing layer for ACP (Agent Client Protocol). Lets multiple clients — desktop, phone, web — attach to one ACP agent session in real time. Each client sees the same conversation, can take turns prompting, and receives streaming updates from the agent.
Status: v0.1.1.
git clone https://github.com/lsaether/acp-mux
cd acp-mux
cargo build --release
# binary: ./target/release/amuxamux --agent-cmd 'hermes acp' --port 8765Then connect WebSocket clients to ws://127.0.0.1:8765/acp?session=<id>&peer_id=<unique>&peer_name=<display>&role=<optional>.
Health and debug endpoints:
GET /healthz—200 okGET /debug/sessions— JSON snapshot of every live session (subscribers, cache state, active turn, replay log length)
| Flag | Default | Notes |
|---|---|---|
--host |
127.0.0.1 |
Bind address. |
--port |
8765 |
TCP port. |
--agent-cmd |
(none) | Command + args (whitespace-split). Without this, subscriber attaches close with WS code 1011. |
--session-ttl-seconds |
60 |
Grace window after last subscriber leaves — a reconnect within this window keeps the same subprocess. |
--replay-turns |
unbounded |
unbounded keeps the full broadcast log; 0 disables it; N > 0 is accepted and warned (bounded eviction lands in v0.2). |
--log-level |
info |
trace/debug/info/warn/error. RUST_LOG wins when set. |
- One subprocess per session. Each
?session=value spawns a fresh--agent-cmdsubprocess. Multiple subscribers on the same session share that subprocess. - JSON-RPC envelope routing. The mux parses only the envelope (
id,method,params,result,error). Payloads are forwarded byte-for-byte. Policy keys off themethodstring. - Per-session id translation. Each subscriber's request
idis rewritten to a per-sessionmux_idbefore forwarding; the response is rewritten back and sent only to the originator. initialize/session/newcaching. First response is cached; later joiners are answered locally without re-sending to the agent.- Broadcast agent-initiated requests. Agent-initiated requests (e.g.
session/request_permission) are fanned out to every attached subscriber; any peer can reply. The first reply for a given id is forwarded to the agent and later replies for the same id are dropped, so the agent always sees exactly one response. - Turn serialization. Concurrent
session/promptwhile a turn is in flight is rejected with JSON-RPC-32001. The last subscriber to issue a substantive request is still surfaced as the "driving subscriber" in/debug/sessionsandamux/turn_startedfor UI attribution. amux/*notification namespace. The mux publishes its own metadata out-of-band:amux/peer_joined,amux/peer_left,amux/turn_started,amux/turn_complete,amux/turn_cancelled,amux/session_busy,amux/agent_request_resolved. ACP frames stay clean; clients see two distinguishable channels and demultiplex by method prefix.- Cancellation.
$/cancel_request(request-cancellation RFD) works both directions: subscribers can cancel their own in-flight requests; agents can cancel agent-initiated requests (broadcast to peers +amux/agent_request_resolved { resolvedBy: "agent:cancelled" }). The amux extensionamux/cancel_active_turnlets any attached peer cancel the in-flight turn (not just the driver) — internally it synthesizes a$/cancel_requesttoward the agent and emitsamux/turn_cancelledto peers. - Replay log. Every broadcast-tier frame (
amux/*+ agent notifications) is appended; a late joiner receives the full history before any live event. - TTL grace. Last subscriber leaving starts a countdown; a reconnect within
--session-ttl-secondsreuses the same subprocess with all of its caches intact.
Clients SHOULD:
- Treat
amux/peer_joined(withpeerId == self.peer_id) as the empty-roster signal — used only by replay log late joiners. - Treat
amux/turn_started/amux/turn_completeas turn bookends; thepeerIdfield attributes the turn. - Filter
amux/*frames out of the conversation render and use them for presence / turn UI. - Allow the mux to rewrite request
idfields freely (preserve client-side correlation by tracking your own original ids).
Detailed protocol spec: docs/design/amux-namespace.md.
amux parses only JSON-RPC envelopes (id, method, params, result, error) and forwards payloads byte-for-byte. Any ACP method amux doesn't specifically intercept passes through transparently. The table below lists methods that need special handling and where amux stands.
"Spec status" reflects the upstream ACP lifecycle (RFD process): Core = part of the stable spec; Draft RFD = merged into docs/rfds/ on main but not yet promoted to Preview/Completed (implementations may begin, not a stability commitment); Open RFD = still an unmerged PR.
| Method | amux | Spec status | Notes |
|---|---|---|---|
initialize |
✅ | Core | Forwarded; first response cached; agentCapabilities from the upstream agent passed through. |
session/new |
✅ | Core | Forwarded; first response cached for late joiners. |
session/load |
✅ | Core | Forwarded to the agent like any other request. On success, amux rebinds the room's canonical session id (used by late joiners' session/new calls) to the loaded session; failed loads leave the cache untouched. |
session/prompt |
✅ | Core | Forwarded with id translation; turn serialization; concurrent prompts rejected with -32001. |
session/cancel |
✅ (envelope passthrough) | Core | Per-turn notification; flows through unchanged. |
session/set_mode |
✅ (envelope passthrough) | Core | Not specifically handled. |
$/cancel_request |
✅ | Draft RFD (optional per spec) | Strict per-peer semantics; cancels own in-flight requests only. |
session/attach, session/detach |
⏳ | Open RFD (#533) | Implemented on branch rfd-533-alignment, shelved pending RFD ratification. |
session/list |
✅ (envelope passthrough) | Draft RFD | Forwarded to the agent like any other request; agent's response (with the sessions[] array) flows back unmodified. Capability advertisement (sessionCapabilities.list) propagates from the agent. Decorating the response with amux-known fields (_meta.amuxSubscriberCount, etc.) is tracked in #6. |
| Method | amux | Spec status | Notes |
|---|---|---|---|
session/update |
✅ | Core | Broadcast to every attached subscriber; appended to replay log. |
session/request_permission |
✅ | Core | Broadcast with first-writer-wins reply; amux/agent_request_resolved fires when consumed; turn-end sweep cleans up abandoned requests. |
$/cancel_request |
✅ | Draft RFD (optional per spec) | Marks agent_pending Consumed; broadcasts to all peers; emits amux/agent_request_resolved { resolvedBy: "agent:cancelled" }. |
fs/read_text_file, fs/write_text_file |
❌ | Core | Tracked in #2. amux currently broadcasts these to subscribers, which is broken for any agent that delegates fs to the client (Codex, claude-code-acp, copilot-acp). Self-handling design agreed; implementation deferred. |
terminal/create, terminal/output, terminal/wait_for_exit, terminal/kill, terminal/release |
❌ | Core | Same as fs/* — tracked in #2. |
- hermes-agent — fully supported. hermes self-handles fs/terminal in its own process and never delegates over ACP, so issue #2 doesn't apply.
- Codex (Zed-bundled), claude-code-acp, copilot-acp — partially supported. Conversation, permissions, and cancellation work; fs/terminal delegation will misbehave until #2 lands.
| Method | Direction | Purpose |
|---|---|---|
amux/peer_joined, amux/peer_left |
proxy → subscribers | Presence. |
amux/turn_started, amux/turn_complete |
proxy → subscribers | Turn bookends with amuxTurnId. |
amux/turn_cancelled |
proxy → subscribers | Intent broadcast when any peer triggers cancellation. |
amux/session_busy |
proxy → subscribers | Companion to -32001 rejection on concurrent prompts. |
amux/agent_request_resolved |
proxy → subscribers | Dismissal signal for agent-initiated requests (request_permission, etc.). |
amux/cancel_active_turn |
subscriber → proxy | Any peer can cancel the active turn; resolves to a synthesized $/cancel_request toward the agent. |
Detailed shape and semantics: docs/design/amux-namespace.md.
- Protocol spec:
docs/design/amux-namespace.md - Build plan:
ROADMAP.md - Release notes:
CHANGELOG.md
MIT — see LICENSE.