Framing and JSON-RPC 2.0 encoding for the Agent Client Protocol.
Pure functions only — no process, no I/O. The peer owns the socket; this module owns the bytes, which is the part worth testing exhaustively and the part that has nothing to do with sprites.
ACP is newline-delimited JSON over stdio. A transport hands the peer bytes
in arbitrary chunks that respect no message boundary at all: one chunk can carry
three messages and half of a fourth, and the other half arrives later. So
framing has to carry a buffer across calls — feed/2 returns the messages it
could complete plus whatever tail it could not, and the caller hands that tail
back on the next chunk.
Why the decode failure is a value and not a raise
A line that is not JSON is not an exception, it is an event: adapters print
warnings, npm prints deprecation notices, and a Node process writes its stack
trace to stdout when it dies. feed/2 returns {:invalid, line} for those so
the peer can log them as ordinary output rather than crashing a turn on
somebody else's diagnostic.
Summary
Functions
Classify a decoded JSON-RPC object.
Classify one already-framed line.
The client capabilities the peer declares unless told otherwise: none.
Encode an error reply.
Frame data against a carried buffer, returning complete messages and the
new buffer.
The initialize params for client_capabilities.
JSON-RPC's method-not-found code.
Encode an outbound notification (no id, no reply expected).
Encode an outbound request. Returns the line, newline included.
Encode a successful reply to an agent→client request.
Whether this line is a session/update carrying session metadata rather
than agent activity.
Types
@type message() :: {:response, id :: term(), result :: map()} | {:error_response, id :: term(), error :: map()} | {:request, id :: term(), method :: String.t(), params :: map()} | {:notification, method :: String.t(), params :: map()} | {:invalid, line :: String.t()}
A framed message, already classified.
Functions
Classify a decoded JSON-RPC object.
Classify one already-framed line.
Exposed for a render path that reads stored lines back and has no buffer to carry.
@spec default_client_capabilities() :: map()
The client capabilities the peer declares unless told otherwise: none.
fs/* and terminal/* are client-implemented, and a client that declares
them has to service them against wherever the agent is running. Declaring
nothing means a well-behaved adapter never asks; the peer still answers
anything that arrives with method_not_found/0, because an unanswered
request blocks the agent.
Encode an error reply.
-32601 (method not found) is the one gate 2 sends: we declare no client
filesystem or terminal capabilities, so an adapter calling fs/* or
terminal/* is asking for something we told it we do not have. Answering is
still mandatory — an unanswered request blocks the agent, and a blocked agent
is a sprite billing until the lifetime ceiling.
Frame data against a carried buffer, returning complete messages and the
new buffer.
The tail after the last newline is always incomplete by definition — a message is only whole once its newline has arrived — so it goes back into the buffer even when it happens to be valid JSON.
The initialize params for client_capabilities.
protocolVersion is 1, the current version at
agentclientprotocol.com;
the spec bumps it only for breaking changes, and new capabilities are not
breaking. The peer sends this on handle_continue(:initialize) with the
capabilities its owner passed (default_client_capabilities/0 by default).
@spec method_not_found() :: integer()
JSON-RPC's method-not-found code.
Encode an outbound notification (no id, no reply expected).
Encode an outbound request. Returns the line, newline included.
Encode a successful reply to an agent→client request.
Whether this line is a session/update carrying session metadata rather
than agent activity.
The claude adapter generates the session title asynchronously and writes the
session_info_update about a second after the prompt response — out of
turn. Metadata is nothing the agent said or ran, so it must not be read as
"the agent is talking out of turn": it opens no autonomous turn and holds
none open (#1300). Exposed for an owner that classifies the peer's reported
lines.