# `Forcola.Duplex`
[🔗](https://github.com/joshrotenberg/forcola/blob/v0.3.2/lib/forcola/duplex.ex#L1)

A bidirectional stdin/stdout session with an external process.

For interactive CLIs driven over stdin (agent CLIs in stream-json mode).
The owner writes lines in and receives lines out; the child runs in its
own process group and dies with the session.

    {:ok, session} = Forcola.Duplex.open(["claude", "--input-format", "stream-json"], [])
    :ok = Forcola.Duplex.send_line(session, json)
    receive do
      {:forcola_line, ^session, line} -> line
    end
    :ok = Forcola.Duplex.close(session)

## Messages

The process that called `open/2` (the owner) receives:

  * `{:forcola_line, session, line}` - a stdout line, without its
    trailing newline. A partial line held across frames is delivered
    once its newline arrives; a final partial line is delivered before
    the exit message.
  * `{:forcola_stderr, session, line}` - a stderr line, unless
    `merge_stderr: true` routed stderr into `:forcola_line`. Under
    `pty: true` a terminal carries a single stream, so stderr is always
    merged into `:forcola_line` and no `:forcola_stderr` messages arrive.
  * `{:forcola_exit, session, status}` - the child exited on its own;
    `status` is the exit code, `{:signal, n}` for death by signal,
    `{:spawn_error, reason}` if it never started, or `:shim_exited` if
    the shim died without reporting. The session is over; `close/1` is
    not required (but is harmless).

## Kill discipline

`close/1` kills the child's process group (SIGTERM, then SIGKILL after
the kill grace) and blocks until the shim confirms the group is dead.
The session monitors its owner: owner death takes the same path. If
the session process itself is killed brutally, or the whole BEAM dies,
the port closes, the shim sees stdin EOF, and the group is killed
anyway.

For CLIs that exit when their stdin closes, `send_eof/1` closes the
child's stdin without killing anything; the child's own exit then
arrives as a `:forcola_exit` message.

## Pseudo-terminal

`open/2` with `pty: true` runs the child under a pseudo-terminal instead
of pipes. CLIs that detect a tty behave as they do in a real terminal:
line buffering rather than block buffering, color output, progress
rendering, and interactive prompts (password entry, pagers, REPLs, TUIs).

A terminal carries one bidirectional stream, so a pty merges the child's
stdout and stderr: all output arrives as `{:forcola_line, ...}` and no
`:forcola_stderr` messages are produced. `merge_stderr: false` contradicts
this and raises `ArgumentError`. An initial window size can be set with
`:pty_rows` and `:pty_cols`; there is no dynamic resize yet.

# `session`

```elixir
@opaque session()
```

An open duplex session.

# `child_spec`

Returns a specification to start this module under a supervisor.

See `Supervisor`.

# `close`

```elixir
@spec close(session()) :: :ok
```

Close the session and kill the child's process group.

Blocks until the shim confirms the group is dead. Idempotent: closing
a session that is already over returns `:ok`.

# `open`

```elixir
@spec open(
  [String.t(), ...],
  keyword()
) :: {:ok, session()} | {:error, term()}
```

Open a duplex session running `argv`; the caller becomes the owner.

## Options

  * `:cd`, `:env`, `:merge_stderr` - as in `Forcola.run/2`.
  * `:user`, `:group` - run the child as a different user/group, as in
    `Forcola.run/2`. POSIX-only, a one-way drop, and requires a
    privileged shim; failures fail closed and arrive as
    `{:forcola_exit, session, {:spawn_error, reason}}`.
  * `:cgroup` - opt-in Linux cgroup v2 containment of deliberate
    daemonizers, as in `Forcola.run/2`. Linux only, requires a delegated
    cgroup v2 subtree, and falls back to the process-group kill with a
    warning elsewhere. Default `false`.
  * `:kill_grace_ms` - SIGTERM-to-SIGKILL grace, default `5_000`.
  * `:pty` - run the child under a pseudo-terminal (default `false`). In
    pty mode stderr is merged into `:forcola_line` and no `:forcola_stderr`
    messages arrive; passing `merge_stderr: false` raises `ArgumentError`.
  * `:pty_rows`, `:pty_cols` - initial pty window size, applied only when
    `pty: true`.

There is no `:timeout_ms`; the session is bounded by its owner process
and `close/1`. Passing `:timeout_ms` raises `ArgumentError`.

A spawn failure (e.g. a missing binary) is asynchronous: `open/2`
still returns `{:ok, session}` and the failure arrives as
`{:forcola_exit, session, {:spawn_error, reason}}`.

# `send_eof`

```elixir
@spec send_eof(session()) :: :ok | {:error, term()}
```

Close the child's stdin without killing the group.

For CLIs that finish and exit when their input ends; the child's exit
then arrives as a `:forcola_exit` message. Returns `{:error, :closed}`
if the session is already over.

# `send_line`

```elixir
@spec send_line(session(), iodata()) :: :ok | {:error, term()}
```

Write a line to the child's stdin.

A newline is appended. Returns `{:error, :closed}` once the session is
over or the child's stdin has been closed with `send_eof/1`.

---

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