Managoat.ACP.Peer (managoat_acp v0.1.0)

Copy Markdown View Source

One ACP connection, for as many turns as its owner sends it.

The peer drives initialize → (session/new | session/resume | session/load) → session/prompt over whatever transport the host gives it (Managoat.ACP.Transport), translates nothing itself, and — once the prompt is answered — waits in :idle for the next prompt/3 rather than ending. The connection ends when the owner calls close/1, when the owner dies, or when a write fails.

Built as gate 2 of Fountain's ADR 0014 and widened by #817; the issue numbers below are that repository's, kept because each one is a measured agent behaviour the code is shaped around.

Why this is its own process

The host owns the sandbox (or port, or socket), the turn record and the output budget; the peer owns a protocol conversation with states and a correlation table. They fail differently and should not share a mailbox. A protocol bug here must fail a turn, so the peer is deliberately unlinked from its owner in both directions and monitored instead: linking would make it take down an owner that is also holding a sandbox handle and a tenant's secrets.

Lifetime is the wake, not the turn

0014 as first written scoped the connection to the turn: one session/prompt, take the stopReason, close. #817 measured what that costs. The adapter treats the connection as the session, so closing it at end_turn kills everything Claude Code left running in the background — a Monitor, a run_in_background shell, a ScheduleWakeup — and throws away codex's "Allow for Session" grant; and ACP allows session/update out of turn, which a client that stops reading at the prompt response never sees.

So the peer outlives its prompt. After the prompt's response it reports {:done, stop, usage} and moves to :idle, where the owner may send the next turn with prompt/3 on the same connection — no second initialize, no session/resume, no model pin. Updates that arrive while idle are still reported as {:lines, "acp", …}; an autonomous cycle (a background task's follow-up) is marked at its end by {:cycle_end, kind} when the adapter's usage_update carries an origin from its autonomous set. The owner decides what a turn is; the peer only parses the protocol.

What stays scoped to the turn is the owner's accounting — its turn record, its output budget, its notion of busy — and what bounds the connection is the owner: it closes the peer with close/1 when the transport stops being its own (the sandbox is parked or destroyed, the owner shuts down), never at turn end. The durable identity is the session id the agent returned, which the owner keeps and hands to session/resume on the next connection.

Resumption, and why session/load is the unhappy path

session/resume restores context and returns. session/load "MUST replay the entire conversation to the Client in the form of session/update notifications" before responding — and an owner that persists what the peer reports already holds that history, so relaying the replay would duplicate the whole transcript on every turn after the first. While a session/load is outstanding the peer runs in replay-discard mode and drops updates on the floor.

The response is not the end of the replay, and we do not treat it as one. Gemini answers session/load before replaying — its streamHistory is a floating promise — so a window closed on the response misses the replay entirely (#657, live: one assistant message rendered twice). The window closes instead on a bounded quiet period: keep discarding until the stream has been silent for @replay_quiet_ms, then prompt, giving up after @replay_max_ms because a turn held open disarms idle reclaim (#413).

We prefer resume whenever the adapter advertises it, which the pinned Claude adapter does. load is kept because the capability is per-adapter and per version, and discovering at runtime that this build cannot resume is better handled by taking the expensive path than by failing the turn.

Reattaching after a restart

A deploy restarts every owner, and with it every peer — but an adapter in a sandbox is a detachable session that keeps running, mid-turn, with a session/prompt still outstanding. attach: prompt_id starts a peer for that turn without a handshake: it joins the stream already in flight, answers the agent's requests (a session/request_permission nobody answers is a turn that never ends), and closes the turn on the response to prompt_id. Everything else in the replayed prefix — the handshake responses, a model rejection the previous peer already reported — is history and is dropped rather than re-acted-on.

The prompt id has to come from the caller because it is the only way to tell the prompt's answer from a replayed handshake response; the peer reports {:prompt_sent, id} the moment it writes the prompt so the owner can persist it for exactly this purpose. Without it a reattached turn cannot be resumed at all, and the caller orphans it instead.

A sandbox that replays its buffer on attach replays the tail (Sprites, measured: one 16 KiB chunk, starting mid-line — not from the beginning), so an attached peer drops the partial first line and leaves de-duplicating the replayed lines to the owner, by content, not by byte count.

What it sends back

Everything goes to the owner as {:acp, ref, payload} — the peer persists nothing and writes nothing but protocol. An owner keeps its own invariants (an output budget, a redaction pass, replay de-duplication) on its own persistence path, and a peer writing rows directly would bypass them all. The README lists every payload and what an owner is expected to do with it.

Summary

Types

What the peer reports upward, as {:acp, ref, payload}. ref is whatever the owner passed as :ref.

Functions

Answer an outstanding session/request_permission with one of the options the agent offered.

Ask the agent to stop the current turn.

Returns a specification to start this module under a supervisor.

Close the connection cleanly.

Refuse an outstanding permission request without a human answering.

Send the next turn on a connection that is :idle — the previous prompt was answered and the peer stayed up (#817).

Start a peer and send its first turn.

Feed a chunk of inbound bytes. Chunks respect no message boundary.

Types

payload()

@type payload() ::
  {:lines, stream :: String.t(), data :: String.t()}
  | {:session, String.t()}
  | {:prompt_sent, pos_integer()}
  | {:model_rejected, requested :: String.t(), detail :: String.t()}
  | {:handshake_ms, non_neg_integer(), method :: String.t()}
  | {:done, stop_reason :: String.t(), usage :: map() | nil}
  | {:cycle_end, kind :: String.t()}
  | {:permission_ask, request_id :: String.t(), tool :: String.t() | nil,
     options :: [map()]}
  | {:permission_denied, tool :: String.t() | nil, verdict :: String.t()}
  | {:failed, term()}

What the peer reports upward, as {:acp, ref, payload}. ref is whatever the owner passed as :ref.

Functions

answer_permission(pid, request_id, option_id)

@spec answer_permission(pid(), String.t(), String.t()) ::
  :ok | {:error, :no_pending_permission | :unknown_option}

Answer an outstanding session/request_permission with one of the options the agent offered.

option_id must be an id from that request's own option list; anything else is refused rather than forwarded, which is the fail-closed rule applied to the answer side. Returns {:error, :no_pending_permission} when the request has already been answered — by another client, by the timeout, or by the turn ending — so a caller can tell "too late" from "wrong option".

cancel(pid)

Ask the agent to stop the current turn.

session/cancel is a notification with no reply; the agent answers the original session/prompt with a cancelled stop reason, which is what actually ends the turn.

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

close(pid)

@spec close(pid()) :: :ok

Close the connection cleanly.

The owner calls this when the transport stops being its own — the sandbox is parked or destroyed, the owner shuts down. The peer stops with :normal; the transport is the owner's to stop. Safe to call on a peer that has already gone.

deny_permission(pid, request_id)

@spec deny_permission(pid(), String.t()) :: :ok

Refuse an outstanding permission request without a human answering.

This is what the timeout fires, and what the turn's end calls if a request is still open. Picks a reject_* the agent offered, or cancelled when it offered none — never an allow.

prompt(pid, prompt, images \\ [])

@spec prompt(pid(), String.t(), [map()]) :: :ok | {:error, {:not_idle, atom()}}

Send the next turn on a connection that is :idle — the previous prompt was answered and the peer stayed up (#817).

Reuses the session already open on this connection: no handshake, no session/resume, no model pin. Reports {:prompt_sent, id} exactly as the first prompt did, so the reattach contract is unchanged. Refused with {:error, {:not_idle, phase}} while a prompt is outstanding or the connection is still being set up — never a silent second prompt on the wire.

start(opts)

@spec start(keyword()) :: GenServer.on_start()

Start a peer and send its first turn.

Required: :owner (the pid reports go to, monitored), :writer (a Managoat.ACP.Transport.writer/0), :ref (echoed in every report), :prompt, :mode (:run opens a session with session/new; :continue resumes :session_id with session/resume or session/load) and :session_id (nil for :run).

Optional: :cwd (default "/home/sprite"), :images ([%{media_type:, data:}], raw bytes), :mcp_servers (already in ACP's shape), :model (pinned through session/set_config_option or session/set_model when the agent advertises either), :permission_policy (see Managoat.ACP.Permissions), :pending_permission (a held request handed back on reattach), :client_capabilities (default Managoat.ACP.Protocol.default_client_capabilities/0), :replay_quiet_ms and :replay_max_ms (the session/load window), and :attach.

attach: prompt_id skips the handshake and resumes a turn whose session/prompt (with that JSON-RPC id) is already outstanding on the transport — see the moduledoc. :session_id must be the live session's id so cancel/1 still works.

The peer is started unlinked (GenServer.start/2): the owner monitors it, it monitors the owner, and neither takes the other down.

stdout(pid, data)

@spec stdout(pid(), binary()) :: :ok

Feed a chunk of inbound bytes. Chunks respect no message boundary.

The host calls this from wherever its bytes arrive — a sandbox's stdout message, a port's data message, a socket frame.