The ACP agent (server side) over stdio (ADR 0009): a single GenServer that owns the
stdout writer and the acp_session_id ↔ pixir_session_id map, decodes ndjson JSON-RPC
from stdin, dispatches by method onto Pixir.Conversation, and runs each
session/prompt in a supervised Task.
Channel discipline (ADR 0005)
stdout carries only JSON-RPC. Every write goes through this one process, so the
ndjson stream never interleaves. Prompt Tasks never touch stdout directly — they call
emit/2. Diagnostics go to stderr. The caller (run/0) redirects Logger to stderr
before starting so no log line corrupts the stream.
stdin
A dedicated reader process blocks on IO.read(io, :line) and forwards {:line, l} /
:eof / {:io_error, r} to this server, so the server mailbox is never blocked on raw
stdin. run/0 explicitly configures stdio as Unicode because GUI launchers can start
Pixir without a UTF-8 locale; ACP wire text must remain UTF-8 regardless of the parent
process environment. On EOF the server stops normally and run/0 unblocks (exit 0).
Scope
Implements initialize, session/new, session/prompt, session/cancel,
authenticate + logout (ACP handshake no-ops; Pixir advertises terminal
auth through pixir login, and owns Credential storage outside the stdio channel),
session/set_mode + session/set_config_option (modes, models, and reasoning effort,
D.2), session/set_model (legacy Pixir/T3 compatibility), and session/load + session/resume
(lifecycle, A.6); emits session/update (incl. current_mode_update, plan, and the
additive runtime-driven config_option_update, #520) and ORIGINATES session/request_permission (interactive permissions,
A.2 — correlating the client's response against pending_requests). Per-turn
knobs (model, reasoning effort, hosted Web Search, permission_mode) ride on
session/prompt _meta; sticky model, reasoning-effort, and Web Search
selections are exposed through configOptions; the legacy model catalog +
auth status ride on initialize._meta.pixir.
Other methods get -32601. JSON-RPC errors are reserved for protocol faults; a
failed Turn is reported as content with stopReason:"end_turn" (ADR 0009 §5), and
the prompt result additionally carries _meta.pixir.turn_failure exactly when a
turn_failed event was observed during the prompt. Availability is owned jointly by
ACP's prompt table and the underlying Session: a terminal update normally remains behind
a bounded cleanup wait before its PromptResponse, and any residual :busy race is an
explicit -32602 refusal rather than an empty successful Turn. A stalled Session probe
or a successor Turn can never hold the ACP Server or the completed request id indefinitely.
Failure facts are allowlisted: terminal_status is one of the current producer
statuses (configuration_error, provider_error, tool_error, or interrupted),
while error_kind is a lower-case ASCII identifier of at most 64 UTF-8 bytes. A
cleanup interrupted classification does not replace earlier non-empty facts from the
same prompt. Malformed facts are omitted, though an observed turn_failed still
projects an empty facts map so evidence presence is preserved. A refused prompt or
silent stall claims no failure evidence.
Permission posture follows the session mode (plan → read-only) and
_meta.permission_mode "ask" (→ interactive approval via the ACP asker).
TODO(presenter-session-id)
ACP clients already receive the Pixir Session id from session/new, but tool/model
projections can still make the parent id invisible to the assistant text layer. The
next Presenter slice should expose the parent pixir_session_id consistently in
Pixir-specific _meta, tool result raw output, or session/status updates so T3/Zed
prompts can report it without guessing from child ids. Keep this presentation-only:
the Log remains authoritative and stdout must remain JSON-RPC only.
Summary
Functions
Returns a specification to start this module under a supervisor.
Emit a session/update notification (called by prompt Tasks; serializes writes).
Translate and emit a Pixir Event with server-owned presentation state.
Feed one already-decoded JSON-RPC line into the Server. Test seam that drives the same
handle_info({:line, _}) path the reader uses, without real stdio.
Originate a session/request_permission request to the client and BLOCK until
the client responds (A.2). Returns the raw RequestPermissionResponse result
(a map) for Translate.permission_outcome/1 to interpret, or {:error, reason}.
Blocking entrypoint for pixir acp. Redirects Logger to stderr, starts a linked
Server reading :stdio, and blocks until the Server stops on EOF. Returns :ok so the
CLI router exits 0.
Apply a runtime-owned config change for one ACP session and push it to the
client without any client round-trip (#520). After validating and storing the
new sticky value(s), the Server emits ONE session/update carrying
sessionUpdate: "config_option_update" with the COMPLETE configOptions
list reflecting the new values (ACP: an update replaces the advertised set).
A runtime mode change additionally emits the existing additive
current_mode_update, mirroring the client-driven session/set_mode path.
Start the Server. Opts
Functions
Returns a specification to start this module under a supervisor.
See Supervisor.
@spec emit(GenServer.server(), map()) :: :ok
Emit a session/update notification (called by prompt Tasks; serializes writes).
@spec emit_event(GenServer.server(), binary(), Pixir.Event.t()) :: :ok
Translate and emit a Pixir Event with server-owned presentation state.
@spec feed(GenServer.server(), binary()) :: :ok
Feed one already-decoded JSON-RPC line into the Server. Test seam that drives the same
handle_info({:line, _}) path the reader uses, without real stdio.
@spec request_permission(GenServer.server(), map()) :: {:ok, map()} | {:error, term()}
Originate a session/request_permission request to the client and BLOCK until
the client responds (A.2). Returns the raw RequestPermissionResponse result
(a map) for Translate.permission_outcome/1 to interpret, or {:error, reason}.
Called from inside the Executor's Task (the Turn's tool loop), so blocking here blocks only that one Task — never the Server GenServer (which keeps writing and reading lines, including the eventual response). The Server owns the timeout and removes the pending request if a silent client never replies.
@spec run() :: :ok
Blocking entrypoint for pixir acp. Redirects Logger to stderr, starts a linked
Server reading :stdio, and blocks until the Server stops on EOF. Returns :ok so the
CLI router exits 0.
@spec runtime_config_change(GenServer.server(), binary(), map()) :: {:ok, :queued} | {:error, %{kind: :invalid_args, details: map()}}
Apply a runtime-owned config change for one ACP session and push it to the
client without any client round-trip (#520). After validating and storing the
new sticky value(s), the Server emits ONE session/update carrying
sessionUpdate: "config_option_update" with the COMPLETE configOptions
list reflecting the new values (ACP: an update replaces the advertised set).
A runtime mode change additionally emits the existing additive
current_mode_update, mirroring the client-driven session/set_mode path.
changes maps any of "mode", "model", or "reasoning_effort" to its new
id. Invalid entries are dropped with a stderr warning; unknown sessions are
ignored (diagnostics to stderr; stdout stays JSON-RPC only). Returns
{:ok, :queued} when the change was handed to the Server, or a structured
{:error, %{kind: :invalid_args}} for malformed arguments. Presenter
plumbing only — the Log remains authoritative. The live plan→build producer
is Pixir.ACP.RuntimeMode.leave_plan/1, called from update_plan.
@spec start_link(keyword()) :: GenServer.on_start()
Start the Server. Opts:
:io— the stdio device (default:stdio; inject aStringIO/pipe in tests).:provider,:provider_opts— passed through to each Turn (test seam).:prompt_resolve_hook— test callback after the bounded Session Turn cleanup wait and immediately before prompt resolution.:prompt_before_cleanup_hook— test callback after terminal status and before the bounded Session cleanup wait.:compaction_complete— test seam for the runtime-owned structured compaction completion; production usesPixir.Compaction.complete/2.:name— optional registered name.