# `Raxol.AgentClientProtocol.Transport.Stdio`
[🔗](https://github.com/DROOdotFOO/raxol-agent/blob/raxol_agent_client_protocol-v0.1.0/packages/raxol_agent_client_protocol/lib/raxol/agent_client_protocol/transport/stdio.ex#L1)

Newline-delimited JSON (NDJSON) over a stdio pipe — either our OWN
process's real stdin/stdout (`:self` mode, when this BEAM process IS the
ACP agent/client speaking on its own stdio), or a spawned peer command's
stdio (`:spawn` mode, when we launch and speak to a subprocess).

One `GenServer` owns BOTH directions of the pipe:

  * **Read** — raw bytes arrive from the OS pipe as `Port` `:data`
    messages (`:spawn` mode) or as lines read from the `user` device
    (`:self` mode) and are fed through
    `Raxol.AgentClientProtocol.Transport.Framer` (byte-splitting only:
    buffers partial data, yields complete NDJSON lines, tolerates
    CRLF, caps a frame's size). A complete line nested deeper than the
    depth limit is refused without being parsed; any other line is then
    `Jason.decode/1`'d *tolerantly*: a line that isn't valid JSON, that
    decodes to something other than a JSON object, that is nested too
    deeply, or that the framer itself rejected as oversized, is delivered
    to the owner as
    `{:acp_transport, ref, {:decode_error, reason, raw_line}}` rather
    than crashing the transport or dropping the frame silently. A
    well-formed object is delivered as
    `{:acp_transport, ref, {:message, map}}`. Lines are processed (and
    therefore delivered) strictly in arrival order. Blank lines are
    skipped by the framer without generating a decode error — a
    trailing newline at EOF is common and not itself malformed input.

  * **Write** — `send_message/2` `Jason.encode!/1`s the message, appends
    `"
"`, and writes through THIS GenServer's own mailbox
    (`GenServer.call/2`). This is deliberate single-writer discipline:
    every caller's write is serialized through one process, so two
    concurrent `send_message/2` calls can never interleave their bytes
    on the wire and corrupt a frame — NDJSON has no length prefix, so a
    torn write (line A's bytes and line B's bytes interleaved before
    either `"
"` lands) is unrecoverable, unlike a length-framed
    protocol. Never write to the underlying port through any path other
    than this module.

## Limits

  * `:max_frame_bytes` (default 16 MiB): a frame longer than this is
    refused as `{:decode_error, {:frame_too_large, size}, ""}` and the
    framer resyncs at the next newline. Raise it if prompts carry large
    base64 resources; lower it for a peer you do not trust. In `:spawn`
    mode this also bounds the memory an unterminated line can take. In
    `:self` mode it does not: the `user` device hands this module whole
    lines only, so a line is held by the device until its newline (or
    end of input) arrives, and only then measured.
  * `:max_json_depth` (default 256): a frame whose arrays and objects
    nest deeper than this is refused as
    `{:decode_error, {:too_deep, max_json_depth}, raw_line}` before it is
    decoded. The check scans the line once and skips string contents.

## stdout is protocol-pure

In `:self` mode, this process's real stdout carries the wire protocol.
Nothing else may ever write to it — an errant `IO.puts/1`, a stray
`Logger` line landing on the `:user` group leader, or any other
application output would land mid-frame on a peer that is line-buffered
reading JSON. This module logs unexpected messages via `Logger.debug/1`,
which is safe **only if** the host application's Logger is configured to
a device other than `:user`/stdout (e.g.
`config :logger, :default_handler, config: [type: :standard_error]`, or
an equivalent `:standard_error` console backend). Configuring that is the
host application's responsibility, not this module's — get it wrong and
every log line is a corrupted ACP frame on the wire.

## Modes

  * `start_self/1` — read and write the `user` device, which is the
    BEAM's own stdin and stdout. A `{:fd, 0, 1}` port is not used: under
    `-noshell` the `user` device already reads fd 0, so a second reader
    loses whatever arrived before it opened (a client's `initialize`).
    Only meaningful when this process's real stdio is dedicated to the
    ACP wire protocol and nothing else writes to stdout. Boot forms that
    satisfy this, with stdio piped by the peer: a compiled release; `mix
    run` of an already compiled project (compile first in a separate
    step: Mix prints compile output on stdout); an `elixir` script that
    sends `Mix.install/2`'s output to stderr and blocks until its
    connection ends, as the package README's quickstart agent does. In
    every form the default log handler must write to stderr (see
    "stdout is protocol-pure"). Not for an interactive `iex` session. Both
    devices are switched to `encoding: :latin1`, which moves bytes
    unchanged: the wire is UTF-8 whatever the locale, and a byte that is
    not UTF-8 reaches the JSON decoder as it was sent. A `unicode` device
    would transcode by the locale's rules, and falls back to latin1 for
    good at the first invalid byte it reads.
  * `start_spawn/3` — launch a peer executable via
    `Port.open({:spawn_executable, ...}, ...)` and speak to *its*
    stdin/stdout. The peer's exit is delivered to the owner as
    `{:closed, {:exit_status, code}}`.

## Ownership

Mirrors `Raxol.AgentClientProtocol.Transport.Paired`: a freshly created
handle has no owner (`nil`) — inbound messages are held until
`set_owner/2` adopts it and then delivered in arrival order, so a
supervisor can create the transport before the `Connection` process
that will own it exists.

# `t`

```elixir
@type t() :: %Raxol.AgentClientProtocol.Transport.Stdio{pid: pid()}
```

# `child_spec`

Returns a specification to start this module under a supervisor.

See `Supervisor`.

# `set_owner`

```elixir
@spec set_owner(t(), pid()) :: :ok
```

Set (or replace) the owner process for a handle — mirrors
`Raxol.AgentClientProtocol.Transport.Paired.set_owner/2`.

# `start_self`

```elixir
@spec start_self(keyword()) :: {:ok, t()}
```

Serve the BEAM's own stdin/stdout as the transport. See the moduledoc's
"Modes" section for when this is (and is not) safe to call.

`opts`:

  * `:owner` — pid to receive inbound `{:acp_transport, ...}` messages;
    defaults to `nil` (adopt later via `set_owner/2`).
  * `:input`, `:output` — the IO devices read and written (default
    `:user` for both). Each is sent `:io.setopts(device, encoding:
    :latin1)`; a device that has no options (`{:error, :enotsup}`) is
    used as it is.
  * `:max_frame_bytes`, `:max_json_depth` — see "Limits".

# `start_spawn`

```elixir
@spec start_spawn(String.t(), [String.t()], keyword()) ::
  {:ok, t()} | {:error, :executable_not_found}
```

Spawn `cmd` (resolved via `System.find_executable/1`, so a bare name is
looked up on `$PATH` and an absolute/relative path containing a slash is
used as-is) with `args`, and speak NDJSON over its stdin/stdout.

`opts`:

  * `:owner` — pid to receive inbound messages; defaults to `nil`.
  * `:cd` — working directory for the child process.
  * `:env` — list of `{name, value}` environment overrides, passed
    straight to `Port.open/2`'s `:env` option.
  * `:max_frame_bytes`, `:max_json_depth` — see "Limits".

The child inherits the node's WHOLE environment plus `:env`: this package
does not scrub anything. An agent is third-party code, so a caller on a
node that holds secrets in its environment (API keys, signing keys,
bearer tokens) must unset them itself, e.g. with
`env: [{~c"MY_API_KEY", false}]` (`Port.open/2` unsets a variable whose
value is `false`).

Returns `{:error, :executable_not_found}` if `cmd` cannot be resolved.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
