# CodexWrapperEx

[![CI](https://github.com/genagent/codex_wrapper_ex/actions/workflows/ci.yml/badge.svg)](https://github.com/genagent/codex_wrapper_ex/actions/workflows/ci.yml)
[![Hex.pm](https://img.shields.io/hexpm/v/codex_wrapper.svg)](https://hex.pm/packages/codex_wrapper)
[![Hex.pm Downloads](https://img.shields.io/hexpm/dt/codex_wrapper.svg)](https://hex.pm/packages/codex_wrapper)
[![Docs](https://img.shields.io/badge/hex-docs-blue.svg)](https://hexdocs.pm/codex_wrapper)
[![License](https://img.shields.io/hexpm/l/codex_wrapper.svg)](https://github.com/genagent/codex_wrapper_ex/blob/main/LICENSE)

Elixir wrapper for the [Codex CLI](https://github.com/openai/codex).

Provides a typed interface for executing prompts, streaming responses,
running code reviews, managing multi-turn sessions, and configuring MCP
servers -- all from Elixir.

## Installation

<!-- x-release-please-start-version -->
```elixir
def deps do
  [
    {:codex_wrapper, "~> 0.10.0"}
  ]
end
```
<!-- x-release-please-end -->

Requires the `codex` CLI to be installed and on your PATH (or set `CODEX_CLI`
to point at it).

## Codex CLI compatibility

Tested against codex-cli 0.149.0 and 0.164.0. Both are pinned in PR CI.

The Codex CLI moves quickly and has removed flags between releases, so a
wrapper release is only known to match the version recorded here. Subcommands
this wrapper does not cover -- including the experimental `app-server`,
`remote-control`, `cloud`, and `exec-server` -- are reachable through
[`CodexWrapper.raw/1`](#raw-cli-escape-hatch):

```elixir
CodexWrapper.raw(["some", "new", "subcommand"])
```

The native contract checks help and closed-enum config rejection without authentication
or model inference. Set `CODEX_CONTRACT_BIN` to select an explicit executable and
`CODEX_CONTRACT_VERSION` to require its reported version. Controlled fake binaries
test that the checker can reject drift; they do not replace these native checks.

Keeping the version current is part of the release checklist: bump the line
above whenever the wrapper is verified against a newer CLI. The weekly
`CLI contract` workflow installs the latest `@openai/codex` and runs
`mix codex.contract`, opening a `cli-drift` issue when the wrapper emits a
flag the CLI no longer accepts.

## Quick start

```elixir
# One-shot exec
{:ok, result} = CodexWrapper.exec("Explain this error: ...")
IO.puts(result.stdout)

# With options
{:ok, result} = CodexWrapper.exec("Fix the bug in lib/foo.ex",
  model: "o3",
  working_dir: "/path/to/project",
  full_auto: true
)

# Streaming
CodexWrapper.stream("Implement the feature described in issue #42",
  working_dir: "/path/to/project"
)
|> Stream.each(fn event -> IO.inspect(event.event_type) end)
|> Stream.run()
```

## Prompt transport

`Exec`, `ExecResume`, `Review` and `ExecFork` send prompts on stdin by default.
This also applies to convenience calls and initial or resumed session turns,
for both execute and stream. The argument list contains `-` instead of the
prompt, so prompt size does not consume the operating system's argv budget
and the prompt is absent from process arguments.

Each invocation creates a 0600 file in a private 0700 directory. The launcher
opens the file as stdin and unlinks it before codex runs. The wrapper also
cleans up files after startup failures, timeouts and stream halts. Streams
create their file when enumerated.

Select the previous argv form when needed:

```elixir
CodexWrapper.exec("Explain this error", prompt_input: :argv)

Exec.new("Explain this error")
|> Exec.prompt_input(:argv)
|> Exec.execute(config)
```

All four builders support `prompt_input/2`. Session defaults and per-turn
options accept `:prompt_input` too. A prompt of exactly `-` is always sent as
literal text on stdin, even in argv mode. An omitted optional prompt remains
omitted.

The built-in runners reject oversized argument lists or environment strings
before starting codex, returning `{:error, {:argument_list_too_long, details}}`.
Streams emit `%CodexWrapper.StreamError{}` with that reason. The size check
includes inherited environment, launcher overhead and Linux per-string
limits. Limits are cached for the VM's lifetime. The check reserves 4 KiB below
the aggregate limit reported by `getconf`, discovered through `PATH` with
`/usr/bin/getconf` as a fallback. If discovery fails, the aggregate defaults are
2 MiB on Linux and 1 MiB on macOS (32 KiB on other platforms). Argv mode still exposes
prompt text in the process table and is subject to these limits.

Custom runners receive prompt bytes as `opts[:stdin]`; they must deliver all
bytes and then EOF, or callers must select `prompt_input: :argv`. The built-in
runners preserve their termination behavior: Port and Forcola both send INT,
TERM and KILL, on one-shot runs and streams.

## Code review

```elixir
# Review uncommitted changes
{:ok, result} = CodexWrapper.review(uncommitted: true)

# Review against a base branch
{:ok, result} = CodexWrapper.review(base: "main", model: "o4-mini")

# Review a specific commit
{:ok, result} = CodexWrapper.review(commit: "abc123", title: "PR title")
```

## Multi-turn sessions

```elixir
config = CodexWrapper.Config.new(working_dir: "/path/to/project")
session = CodexWrapper.Session.new(config, model: "o3")

{:ok, session, result} = CodexWrapper.Session.send(session, "What files are here?")
{:ok, session, result} = CodexWrapper.Session.send(session, "Add tests for lib/foo.ex")

CodexWrapper.Session.turn_count(session)
#=> 2

turn = CodexWrapper.Session.last_turn(session)
# Also available as session.last_turn
turn.status
#=> :completed
turn.final_message
#=> "Added tests for lib/foo.ex"

CodexWrapper.Session.total_usage(session)
#=> %{input_tokens: 120, cached_input_tokens: 100, cache_write_input_tokens: nil,
#     output_tokens: 7, reasoning_output_tokens: nil}
```

`send/3` keeps its raw `%Result{}` return value and result history. The updated
session exposes the typed JSON turn as `session.last_turn` or
`Session.last_turn/1`. `SessionServer.last_turn/1` and
`SessionServer.total_usage/1` provide the same access through a session server.
Usage totals sum reported nonnegative integers from recorded turns. A field
is `nil` if it was never reported; the whole total is `nil` if no recorded
turn included usage. Nonzero exits leave the session unchanged and do not
contribute to usage totals. Each recorded send updates a running total;
reading it takes constant time and does not parse the retained stdout again.

Session options are the exec options in the table under
[Exec options](#exec-options) and apply to every turn. Resumed turns run
`codex exec resume`: `:profile`, `:cd`, `:add_dir`, `:color`, `:oss`,
`:local_provider` and `:approve_for_me` are emitted before `resume`
(see [Exec-level options](#exec-level-options-on-resume-fork-and-review)),
and `:sandbox`, `:approval_policy` and `:search` as `-c sandbox_mode="..."`,
`-c approval_policy="..."` and `-c web_search="..."`. `:worktree` and
`:thread_source` apply to the first turn only: `codex exec resume`
rejects `--worktree`, and `--thread-source` classifies a newly created
thread.

## Typed turn results

`CodexWrapper.Turn.from_result/1` projects an existing `--json` result without
starting a process. `%Result{}` continues to hold the raw stdout, stderr and
exit code.

```elixir
config = CodexWrapper.Config.new()
exec = CodexWrapper.Exec.new("Summarize the changes") |> CodexWrapper.Exec.json()
{:ok, result} = CodexWrapper.Exec.execute(exec, config)
turn = CodexWrapper.Turn.from_result(result)

turn.status        # :completed, :failed or :no_terminal
turn.final_message # last completed agent message from the last turn, or nil
turn.thread_id     # thread.started identity, or nil
turn.usage         # five token counters, or nil
turn.error         # native error data, or nil
turn.events        # parsed events, including unrecognized fields
```

The last `turn.completed` or `turn.failed` sets the native turn outcome. A
top-level `error` fails the turn if no terminal event follows it. A nonzero
process exit makes the projection `:failed`, including after a completion
event. `CodexWrapper.exec/2` and `CodexWrapper.review/1` still return
`{:ok, %Result{success: false}}` for a nonzero exit; runner errors return
`{:error, reason}`.

Status, error, and terminal evidence cover the entire event stream. A new
`turn.started` resets only the final message and usage; it does not erase an
earlier reported outcome. For example, a completed turn followed by a new
start still has `status: :completed`, but its message and usage are `nil`
until the new turn supplies them. See `CodexWrapper.Turn` for each field's scope.

Usage has the keys `:input_tokens`, `:cached_input_tokens`,
`:cache_write_input_tokens`, `:output_tokens` and `:reasoning_output_tokens`.
Only nonnegative integers are retained; missing or invalid counters are
`nil`. Original values and any extra fields remain in `turn.events`.

## IEx REPL

Use Codex conversationally from IEx:

```elixir
iex> import CodexWrapper.IEx

iex> chat("explain this codebase", working_dir: ".", model: "o3")
# => prints response

iex> say("now add tests for the retry module")
# => continues the conversation

iex> cost()
# turn count; the Codex CLI reports no cost

iex> history()
# prints full conversation

iex> session_id()
# "abc-123" -- save this to resume later

iex> reset()
# start fresh
```

## Exec builder

For full control, use the `Exec` struct directly:

```elixir
alias CodexWrapper.{Config, Exec}

config = Config.new(working_dir: "/path/to/project")

Exec.new("Fix the tests")
|> Exec.model("o3")
|> Exec.sandbox(:workspace_write)
|> Exec.approval_policy(:never)
|> Exec.search()
|> Exec.execute(config)
```

`approval_policy/2` takes `:untrusted`, `:on_request`, or `:never`, and
emits `-c approval_policy="<value>"`. The Codex CLI removed the
`--ask-for-approval` flag from `exec` in 0.14x; the config key is the
supported equivalent. `:on_failure` was dropped along with the flag and
now raises.

`search/1` enables live web search and `search/2` selects a mode
(`:cached`, `:indexed`, `:live`, `:disabled`):

```elixir
Exec.new("What changed in OTP 27?")
|> Exec.search(:cached)
|> Exec.execute(config)
```

Both emit `-c web_search="<mode>"`. The Codex CLI removed the `--search`
flag from `exec` in 0.14x; the config key is the supported equivalent,
and `:live` is what `--search` used to mean.

`profile/2` selects a named config profile, emitting `--profile <name>`.
The CLI layers `$CODEX_HOME/<name>.config.toml` on top of the base
`config.toml`:

```elixir
Exec.new("Fix the tests")
|> Exec.profile("fast")
|> Exec.execute(config)
```

`ExecResume`, `ExecFork` and `Review` have `profile/2` too, and
`CodexWrapper.exec/2`, `CodexWrapper.review/1` and
`CodexWrapper.Session.new/2` take a `:profile` option. On the
subcommands it is emitted before the subcommand
(`codex exec --profile <name> resume ...`); see
[Exec-level options](#exec-level-options-on-resume-fork-and-review).

How the CLI resolves a profile (checked on codex-cli 0.149.0, 0.164.0 and
0.165.0):

- The profile is the file `$CODEX_HOME/<name>.config.toml`, not a
  `[profiles.<name>]` table. A `config.toml` that still has
  `[profiles.<name>]` or `profile = "<name>"` fails to load when
  `--profile <name>` is given, with an error asking to move the settings
  into `<name>.config.toml`.
- A missing profile file is not an error: the run continues with the base
  config. A misspelled profile name therefore drops whatever sandbox or
  approval settings the file would have carried, without notice.
- With `ignore_user_config/1` the profile file is not read either (0.165.0),
  so the run gets codex defaults plus the `-c` overrides.

### Writable directories and git commits

`add_dir/2` emits `--add-dir <dir>`, which makes the directory writable
alongside the workspace. It widens the sandbox; it is not a read-only
context hint.

Under the `workspace-write` sandbox the workspace's own git metadata stays
read-only, so `git add` and `git commit` run by the agent fail with
`Unable to create '.../index.lock': Operation not permitted`. Measured on
codex-cli 0.165.0 (`codex sandbox` and app-server `command/exec`, outside
`/tmp`, which workspace-write leaves writable):

| Workspace | Extra writable roots | Commit |
|---|---|---|
| plain checkout | none | fails |
| plain checkout | `<repo>/.git` | succeeds |
| linked worktree | none | fails |
| linked worktree | `git rev-parse --git-dir` only | fails (objects live in the common dir) |
| linked worktree | `git rev-parse --git-common-dir` only | fails (the worktree index lives in the git dir) |
| linked worktree | both | succeeds |

To let the agent commit, add the directories git reports:

```bash
git -C <workspace> rev-parse --path-format=absolute --git-dir
git -C <workspace> rev-parse --path-format=absolute --git-common-dir
```

```elixir
Exec.new("Fix the test and commit")
|> Exec.sandbox(:workspace_write)
|> Exec.add_dir(git_dir)
|> Exec.add_dir(git_common_dir)
```

Write access to `.git` also lets the sandboxed agent change hooks and
config that git runs later, outside the sandbox. Committing outside codex
after reviewing the diff stays the conservative option.

### Typed config fields

These builders emit `-c` config fields with values checked before codex
starts. They are on `Exec`, `ExecResume`, `ExecFork` and `Review`, and are
also options on `CodexWrapper.exec/2`, `review/1` and `Session`:

| Builder | Config field | Values |
|---|---|---|
| `reasoning_effort/2` | `model_reasoning_effort` | any non-empty string, e.g. `"high"` |
| `reasoning_summary/2` | `model_reasoning_summary` | `:auto`, `:concise`, `:detailed`, `:none` |
| `verbosity/2` | `model_verbosity` | `:low`, `:medium`, `:high` |
| `approvals_reviewer/2` | `approvals_reviewer` | `:user`, `:auto_review`, `:guardian_subagent` |
| `rollout_budget/2` | `features.rollout_budget` | `limit_tokens:` and `reminder_at_remaining_tokens:` (required), `sampling_token_weight:`, `prefill_token_weight:` |

```elixir
Exec.new("review the migration")
|> Exec.reasoning_effort("high")
|> Exec.reasoning_summary(:concise)
|> Exec.verbosity(:low)
|> Exec.rollout_budget(limit_tokens: 100_000, reminder_at_remaining_tokens: [50_000, 10_000])
# -c model_reasoning_effort="high" -c model_reasoning_summary="concise" -c model_verbosity="low"
# -c features.rollout_budget={enabled=true,limit_tokens=100000,reminder_at_remaining_tokens=[50000,10000]}
```

An invalid value raises `ArgumentError`. The typed fields are emitted after
any `config/2` entries, and codex applies repeated `-c` last-wins, so a
typed builder wins over a `config/2` entry for the same key, as
`approval_policy/2` and `search/1,2` already do.

### Config isolation

By default the CLI loads `$CODEX_HOME/config.toml`, so a programmatic run
silently inherits whatever the developer has configured. Two options take
that away:

```elixir
Exec.new("Summarize the diff")
|> Exec.ignore_user_config()
|> Exec.strict_config()
|> Exec.config(~s(model="o3"))
|> Exec.execute(config)
```

`ignore_user_config/1` skips the config file entirely (auth still
resolves through `CODEX_HOME`). `strict_config/1` makes the run fail on a
config key this Codex version does not recognize, rather than ignoring
it. Together they give a run whose configuration is exactly what the
caller passed.

`ignore_rules/1` is the same idea for execpolicy `.rules` files.

All three are available on `Exec`, `ExecResume`, and `Review`.

### Hook trust

`dangerously_bypass_hook_trust/1` runs enabled hooks without requiring
persisted hook trust. Hook trust is what stops a repository from running
commands the user never approved, so this belongs only in automation that
already vets where its hooks come from. It is separate from
`dangerously_bypass_approvals_and_sandbox/1`; setting one does not set the
other. Available on `Exec`, `ExecResume`, and `Review`.

### Color and local providers

`color/2` (`:always`, `:never`, `:auto`), `oss/1`, and `local_provider/2`
(`"lmstudio"` or `"ollama"`) are on all four builders; the subcommands
emit them before the subcommand.

### Exec-level options on resume, fork and review

`codex exec` reads some options before a subcommand and applies them to
`exec resume`, `exec fork` and `exec review`, while rejecting them after
it. `ExecResume`, `ExecFork` and `Review` emit these between `exec` and
the subcommand: `profile/2`, `add_dir/2`, `cd/2`, `oss/1`,
`local_provider/2`, `color/2` and `approve_for_me/1`.

```elixir
ExecResume.new()
|> ExecResume.session_id(id)
|> ExecResume.prompt("continue")
|> ExecResume.profile("ci")
|> ExecResume.add_dir("/repo/.git")
|> ExecResume.approval_policy(:never)
# codex exec --profile ci --add-dir /repo/.git resume -c approval_policy="never" -- <id> -
```

After the subcommand, all three take `approval_policy/2`, `search/1,2`
(as `-c` fields) and `thread_source/2`; `ExecResume` and `ExecFork` take
`cyber_access_program/2`, which `codex exec review` does not support.
`worktree/1` is on `ExecFork` only: codex-cli 0.165.0 lists `--worktree`
in the resume and review help but rejects it there ("cannot resume an
existing session; use `codex exec fork --worktree`", "not supported for
code review").

## Review builder

```elixir
alias CodexWrapper.{Config, Review}

config = Config.new(working_dir: "/path/to/project")

Review.new()
|> Review.base("main")
|> Review.model("o4-mini")
|> Review.sandbox(:workspace_write)
|> Review.execute(config)
```

`output_schema/2` points `codex exec review` at a JSON Schema file via
`--output-schema`, the same way `Exec.output_schema/2` does, so a review
can return structured output:

```elixir
Review.new()
|> Review.uncommitted()
|> Review.output_schema("/path/to/schema.json")
|> Review.execute(config)
```

`full_auto/1` is still available on `Exec`, `ExecResume` and `Review`,
but it is deprecated upstream: it now selects the `workspace-write`
sandbox rather than emitting the deprecated `--full-auto` flag. An
explicit `sandbox/2` call wins over it.

`sandbox/2` works on all three builders but emits different arguments.
`codex exec` takes `--sandbox <mode>`; `codex exec resume` and `codex
exec review` reject that flag with `unexpected argument`, so on
`ExecResume` and `Review` the builder emits the equivalent config
override `-c sandbox_mode="<mode>"` instead. The public API and the
three accepted modes are the same either way.

## Structured output

`CodexWrapper.Structured.run/4` takes a JSON Schema as a map, writes it to a
private temporary `--output-schema` file for the life of the process, runs
the command with `--json`, and decodes the final agent message. It accepts
`Exec`, `ExecResume`, `ExecFork` and `Review` commands.

```elixir
schema = %{
  "type" => "object",
  "properties" => %{"name" => %{"type" => "string"}},
  "required" => ["name"],
  "additionalProperties" => false
}

{:ok, %{"name" => name}, %{result: _result, events: _events}} =
  CodexWrapper.Exec.new("Extract the name from: Hi, I'm Ada Lovelace.")
  |> CodexWrapper.Structured.run(schema, config)
```

Codex enforces the schema; the wrapper checks only that the final message is
a JSON object or array. Pass `parse: fn value -> {:ok, ...} | {:error, ...} end`
for a caller-side domain check. Failed turns, non-zero exits, missing terminal
events, missing or invalid messages and parser failures are distinct tagged
errors that keep the result and events. `Structured.decode/2` decodes a
`%Result{}` from a run that used an explicit schema path.

## Final message from plain-text runs

`CodexWrapper.LastMessage.run/3` reads the final agent message independently
of merged stdout and stderr, keeping the original process result:

```elixir
{:ok, text, result} =
  CodexWrapper.Exec.new("Summarize the diff in one paragraph")
  |> CodexWrapper.LastMessage.run(CodexWrapper.Config.new())
```

Works with `Exec`, `ExecResume`, `ExecFork` and `Review`. The wrapper creates
a private temporary file and removes it after execution, including failures
and raises. An explicit `output_last_message/2` path remains the caller's.
Missing or empty output on exit zero returns `{:error, {:no_last_message, result}}`;
a non-zero exit returns `{:error, {:process_failed, text, result}}`.

## Session resumption

Resume a previous session:

```elixir
alias CodexWrapper.{Config, ExecResume}

config = Config.new()

# Resume a session
ExecResume.new()
|> ExecResume.session_id("previous-session-id")
|> ExecResume.prompt("Continue where we left off")
|> ExecResume.execute(config)

# Resume the most recent session
ExecResume.new()
|> ExecResume.last()
|> ExecResume.execute(config)
```

### Forking

`CodexWrapper.ExecFork` wraps `codex exec fork <SESSION_ID> [PROMPT]`. It
copies a session's history into a new session and runs the optional
prompt there. The source session is not modified and can still be
resumed under its original ID.

```elixir
alias CodexWrapper.{Config, ExecFork}

config = Config.new()

{:ok, fork} =
  ExecFork.new("source-session-id")
  |> ExecFork.prompt("Try the other approach")
  |> ExecFork.fork(config)

fork.session_id        # the new session, from the CLI's thread.started event
fork.source_session_id # "source-session-id", unchanged
```

`fork/2` forces `--json` and returns `{:error, {:exit, code, result}}` on a
non-zero exit, for example when the source session does not exist.
`execute/2`, `execute_json/2`, and `stream/2` behave as they do on
`ExecResume`. The session ID is validated before the CLI is spawned:
empty IDs, IDs with surrounding whitespace or control characters, and
IDs starting with `-` return `{:error, {:invalid_session_id, value}}`.

`codex exec fork` accepts the `codex exec resume` flags without `--last`
and `--all`, plus `--output-schema`. It rejects `--sandbox`, so
`sandbox/2` emits `-c sandbox_mode="<mode>"` here as well. Verified
against codex-cli 0.149.0.

A CLI without `exec fork` returns `{:error, {:unsupported, :exec_fork}}`.
`ExecFork.supported?/1` checks ahead of time by running
`codex exec fork --help`. `ExecFork.stream/2` has no error tuple to
return, so on such a CLI enumerating the stream raises
`CodexWrapper.UnsupportedError` with `capability: :exec_fork`. The check
only runs when the stream ends without output.

`codex fork`, without `exec`, only runs as an interactive TUI.
`CodexWrapper.Commands.Fork` wrapped it and was removed.

## Retry with backoff

```elixir
alias CodexWrapper.{Config, Exec, Retry}

config = Config.new()
exec = Exec.new("Fix the flaky test")

Retry.execute(exec, config,
  max_retries: 3,
  base_delay_ms: 1_000,
  max_delay_ms: 30_000
)
```

`:retry_on` also receives `{:ok, %Result{success: false}}` for a run that
exited nonzero. The default does not retry those, since a retry re-sends the
prompt and can repeat the failed attempt's tool commands. The wrapper does
not classify rate-limit or capacity failures: codex-cli 0.165.0 reports a
429, 503 or 529 only in its own message text, not as a structured field. See
the `CodexWrapper.Retry` docs for a predicate that matches that text.

## SessionServer (GenServer)

For OTP applications that need a supervised, process-based session:

```elixir
{:ok, pid} = CodexWrapper.SessionServer.start_link(
  config: config,
  exec_opts: [model: "o3"]
)

{:ok, result} = CodexWrapper.SessionServer.send_message(pid, "Fix the tests")
CodexWrapper.SessionServer.turn_count(pid)
```

Works with supervision trees:

```elixir
children = [
  {CodexWrapper.SessionServer,
   name: :my_agent, config: config, exec_opts: [model: "o3"]}
]
```

## MCP server management

```elixir
alias CodexWrapper.Commands.Mcp

config = CodexWrapper.Config.new()

# List MCP servers
{:ok, servers} = Mcp.list(config, json: true)

# Add a stdio transport server
{:ok, _} = Mcp.add(config, "my-server", :stdio,
  command: "npx",
  args: ["-y", "my-mcp-server"],
  env: %{"API_KEY" => "sk-..."}
)

# Add an HTTP transport server
{:ok, _} = Mcp.add(config, "remote", :http,
  url: "https://example.com/mcp",
  bearer_token_env_var: "MY_TOKEN"
)

# Add a server that authenticates over OAuth
{:ok, _} = Mcp.add(config, "oauth-server", :http,
  url: "https://example.com/mcp",
  oauth_client_id: "codex-cli",
  oauth_resource: "https://example.com"
)

# Get server details
{:ok, info} = Mcp.get(config, "my-server", json: true)

# Remove a server
{:ok, _} = Mcp.remove(config, "my-server")
```

### MCP OAuth

`login/3` runs the OAuth flow for a configured server and `logout/2` clears
its credentials.

```elixir
{:ok, _} = Mcp.login(config, "oauth-server")
{:ok, _} = Mcp.login(config, "oauth-server", scopes: ["read", "write"])
{:ok, _} = Mcp.logout(config, "oauth-server")
```

## Running Codex as an MCP server

```elixir
alias CodexWrapper.Commands.McpServer

{:ok, output} = McpServer.start(config,
  config: ["key=value"],
  enable: ["feature-name"]
)
```

## Authentication

```elixir
alias CodexWrapper.Commands.Auth

{:ok, _} = Auth.login(config)
{:ok, _} = Auth.login(config, with_api_key: true)
{:ok, _} = Auth.login(config, with_access_token: true)
{:ok, _} = Auth.status(config)
{:ok, _} = Auth.logout(config)
```

## Feature flags

```elixir
alias CodexWrapper.Commands.Features

{:ok, list} = Features.list(config)
{:ok, _} = Features.enable(config, "my-feature")
{:ok, _} = Features.disable(config, "my-feature")
```

## Sandbox execution

Run commands inside Codex's sandbox:

```elixir
alias CodexWrapper.Commands.Sandbox

Sandbox.new("python3")
|> Sandbox.args(["script.py", "--flag"])
|> Sandbox.execute(config)
```

Sandbox state and permission profiles:

```elixir
Sandbox.new("pytest")
|> Sandbox.permission_profile("ci")
|> Sandbox.sandbox_state_readable_root("/usr/share")
|> Sandbox.sandbox_state_disable_network()
|> Sandbox.cd("/src")
|> Sandbox.execute(config)
```

`Sandbox.new/1` replaces the old `Sandbox.new(platform, command)`. The
Codex CLI dropped the platform subcommand and infers the platform from
the host, so passing an atom now raises with a migration message.

## Diagnostics

`codex doctor` checks the local install, config, auth, and runtime health.
It works as a preflight before a run:

```elixir
alias CodexWrapper.Commands.Doctor

{:ok, report} = Doctor.new() |> Doctor.summary() |> Doctor.execute(config)
```

`execute/2` returns the human-readable report as a string. For the
machine-readable form, `execute_json/2` forces `--json` and decodes it:

```elixir
{:ok, report} = Doctor.new() |> Doctor.execute_json(config)

report["overallStatus"]
#=> "warning"

report["checks"]["auth.credentials"]["status"]
#=> "ok"
```

`codex doctor` exits 0 even when checks report warnings or failures, so a
successful call is not by itself a clean bill of health. Read
`"overallStatus"` rather than relying on the exit code.

Builders: `json/1`, `summary/1`, `all/1`, `no_color/1`, `ascii/1`.

## Shell completions

```elixir
alias CodexWrapper.Commands.Completion

{:ok, script} = Completion.generate(config, :zsh)
```

## Plugins

`CodexWrapper.Commands.Plugin` wraps `codex plugin` and
`codex plugin marketplace`. Every call passes `--json` and returns the
decoded document.

```elixir
alias CodexWrapper.Commands.Plugin

{:ok, %{"installed" => installed, "available" => available}} =
  Plugin.list(config, available: true)

{:ok, _} = Plugin.add(config, "sample@debug")
{:ok, _} = Plugin.remove(config, "sample", marketplace: "debug")

{:ok, _} = Plugin.marketplace_add(config, "https://example.com/plugins.git", ref: "v1")
{:ok, %{"marketplaces" => _}} = Plugin.marketplace_list(config)
{:ok, _} = Plugin.marketplace_upgrade(config)
{:ok, _} = Plugin.marketplace_remove(config, "debug")
```

`add`, `remove` and the marketplace changes edit `$CODEX_HOME/config.toml`
and the plugin cache. A non-zero exit returns `{:error, {:exit, code, output}}`.

## Queueing a message

`CodexWrapper.Commands.Queue.add/4` wraps `codex queue`, which queues a
message for an existing session, named by id or exact session name:

```elixir
{:ok, _output} = CodexWrapper.Commands.Queue.add(config, thread_id, "also run the linter")
```

Codex looks the thread up in its local thread store, or on the app server
given with `remote: "ws://..."`. The message travels in argv, so it is
visible in the process table.

## Session lifecycle

`codex archive`, `codex unarchive`, and `codex delete` operate on a saved
session, named by id (UUID) or session name.

```elixir
alias CodexWrapper.Commands.Archive

{:ok, _} = Archive.archive(config, "abc-123")
{:ok, _} = Archive.unarchive(config, "abc-123")
```

`delete` permanently destroys the session, and `unarchive` cannot bring
it back. So it requires an explicit confirmation; without it the CLI is
never invoked.

```elixir
Archive.delete(config, "abc-123")
# => {:error, :confirmation_required}

Archive.delete(config, "abc-123", confirm: true)
# => {:ok, ""}
```

The same three are available on a `%Session{}`, using its session id:

```elixir
{:ok, _} = CodexWrapper.Session.archive(session)
{:ok, _} = CodexWrapper.Session.unarchive(session)
{:ok, _} = CodexWrapper.Session.delete(session, confirm: true)
```

## Session history

`CodexWrapper.History` reads the rollout files codex writes for each
persisted session under `$CODEX_HOME` (default `~/.codex`):
`sessions/YYYY/MM/DD/rollout-<timestamp>-<id>.jsonl`, the same with
`.jsonl.zst` when codex has compressed it, and both forms under
`archived_sessions/`. It only reads; the commands above change sessions.

```elixir
alias CodexWrapper.History

{:ok, history} = History.home()

# Newest first; archived: true for archived only, :all for both
{:ok, sessions} = History.list_sessions(history, archived: :all, limit: 20)

{:ok, log} = History.read_session(history, hd(sessions).id)
log.meta["cwd"]
Enum.take(log.entries, 5)
# [%{timestamp: "...", type: "session_meta", payload: %{...}, ordinal: 0}, ...]
```

Listing reads file names and sizes only. `read_session/2` resolves the id
when it is called, because codex compresses and archives rollouts, and
returns `entries` as a lazy stream so very large rollouts are read with
bounded memory. Compressed files need OTP 28 or later (`:zstd`); on OTP 27
they return `{:error, {:unsupported, :zstd}}`. `session_index.jsonl`,
`thread_history_1.sqlite` and the app-server thread view are not covered.

They return `{:error, :no_session}` if no turn has run yet, since the
session id only exists once the CLI has created it.

## Applying diffs

```elixir
alias CodexWrapper.Commands.Apply

{:ok, _} = Apply.execute(config, "task-id-from-codex")
```

## Raw CLI escape hatch

For subcommands not yet wrapped:

```elixir
CodexWrapper.raw(["some", "new", "subcommand"])
```

## Telemetry

CodexWrapper emits `:telemetry` span events around its core exec paths so
host applications can observe durations and metadata without
re-implementing instrumentation. Each operation produces a matched
`:start`/`:stop` pair, or a `:start`/`:exception` pair if it raises.
Synchronous calls use `:telemetry.span/3`; lazy streams follow the same
event shape over their full consumption lifecycle.

### Events

| Event                                             | Fired around                                                    |
|---------------------------------------------------|-----------------------------------------------------------------|
| `[:codex_wrapper, :exec, :start \| :stop \| :exception]`     | `CodexWrapper.Exec.execute/2`, `CodexWrapper.ExecResume.execute/2`, `CodexWrapper.ExecFork.execute/2` |
| `[:codex_wrapper, :stream, :start \| :stop \| :exception]`   | `CodexWrapper.Exec.stream/2`, `CodexWrapper.ExecResume.stream/2`, `CodexWrapper.ExecFork.stream/2`, `CodexWrapper.Review.stream/2` |
| `[:codex_wrapper, :review, :start \| :stop \| :exception]`   | `CodexWrapper.Review.execute/2`                                 |
| `[:codex_wrapper, :session, :turn, :start \| :stop \| :exception]` | each synchronous `CodexWrapper.Session.send/3` turn (Exec or Resume) |

`execute_json/2` delegates to its corresponding `execute/2` path, so it
emits the same exec or review event rather than an additional
JSON-specific event.

Stream `:start` fires when the returned enumerable is first reduced, not
when it is constructed. `:stop` fires after the producer is exhausted or
the consumer halts early, after producer cleanup has run.

### Metadata

Start metadata (all events):

- `:command` -- atom identifying the command path (`:exec`,
  `:exec_resume`, `:exec_fork`, `:review`, `:session_exec`, or
  `:session_resume`)
- `:session_id` -- session identifier when present (for `:exec_fork`,
  the source session being forked)
- `:sandbox_mode` -- sandbox mode atom (`:read_only`, `:workspace_write`,
  `:danger_full_access`) or `nil`
- `:approval_policy` -- approval policy atom (`:untrusted`, `:on_request`,
  `:never`) or `nil`

Stop metadata adds:

- `:exit_code` -- non-negative integer from the subprocess when a
  synchronous call resolves to a `%CodexWrapper.Result{}`. Streaming
  events omit it because the Runner stream contract does not expose the
  subprocess exit code.

Exception metadata adds the standard `:kind`, `:reason`, and
`:stacktrace` fields.

### Attaching a handler

```elixir
:telemetry.attach_many(
  "codex-wrapper-logger",
  [
    [:codex_wrapper, :exec, :stop],
    [:codex_wrapper, :stream, :stop],
    [:codex_wrapper, :review, :stop],
    [:codex_wrapper, :session, :turn, :stop]
  ],
  fn event, measurements, metadata, _config ->
    require Logger
    Logger.info("#{inspect(event)} duration=#{measurements.duration} meta=#{inspect(metadata)}")
  end,
  nil
)
```

## Configuration

### Config options

| Option | Type | Description |
|---|---|---|
| `:binary` | `String.t()` | Path to `codex` binary (auto-discovered if omitted) |
| `:working_dir` | `String.t()` | Working directory for the subprocess |
| `:env` | `[{String.t(), String.t() \| nil \| false}]` | Environment variables for the child; `nil` or `false` removes an inherited variable |
| `:release_env` | `:scrub \| :inherit` | Scrub the host release's boot environment by default when `RELEASE_ROOT` is set; use `:inherit` to opt out |
| `:timeout` | `pos_integer()` | Whole-command timeout in milliseconds; defaults to no whole-run bound for Port streams |
| `:idle_timeout_ms` | `pos_integer() \| nil` | Maximum gap between output frames in a stream; defaults to 300,000 ms, or set `nil` to disable |
| `:runner` | module or shorthand | Per-config runner; snapshots application default when absent |
| `:runner_options` | keyword | Immutable one-shot transport requirements; explicit `[]` overrides application defaults |
| `:verbose` | `boolean()` | Compatibility option: only `false` is supported; `true` raises before CLI execution |

### Removing inherited variables

`{name, nil}` and `{name, false}` in `:env` remove a variable the BEAM
would otherwise pass to `codex`, on every runner path. When a name
appears more than once, the last entry wins. `CodexWrapper.Config.new/1` raises
`ArgumentError` on an empty name, a name containing `=`, a NUL byte, or
a value that is not a string, `nil` or `false`.

`Runner.Port` passes the unset to `Port.open/2`. `Runner.Forcola`
one-shot calls replace the child environment with the computed one
(`env_mode: :replace`). `Forcola.Stream` does not take `env_mode`, so a
Forcola stream runs `/usr/bin/env -u NAME ...` in front of `codex`.

### Running from a release

By default, when the parent VM has `RELEASE_ROOT` set, each child process
starts without inherited `RELEASE_*`, `BINDIR`, or `ROOTDIR` variables. The
wrapper also removes complete PATH entries equal to the parent's `BINDIR`,
`ROOTDIR/bin`, `RELEASE_ROOT/bin`, or either root's direct `erts-<version>/bin`.
Other PATH entries retain their order, including duplicates and empty entries.
This lets commands such as `mix` and `elixir` find the project's runtime instead
of booting against the host release. The project's runtime must be available on
the remaining PATH.

The default applies to execute and stream calls on both runners, sessions,
command modules, and `raw/2`. The parent environment is read when preparing
the spawn and is never modified. Outside a release, nothing is scrubbed.

`Config.env` entries apply after the scrub, so an explicit value can restore
a removed name. An explicit PATH still has the parent's release entries
removed; `{"PATH", nil}` or `{"PATH", false}` keeps PATH unset. With
`runner_options: [env_mode: :replace]`, no inherited PATH is added.

```elixir
# Default behavior inside a release
config = CodexWrapper.Config.new(release_env: :scrub)

# Opt out for a command that deliberately needs the host release environment
config = CodexWrapper.Config.new(release_env: :inherit)
CodexWrapper.exec("inspect the release", release_env: :inherit)

# Obtain unset tuples and a cleaned PATH for a custom environment builder
scrub = CodexWrapper.ChildEnv.release_scrub(System.get_env())
```

Removing `RELEASE_COOKIE` reduces accidental propagation. It does not prevent
a child running as the same OS user from reading the release's cookie file.

### Observing native thread identity before completion

The opt-in `:session_observer` execution option announces the first valid
stdout `thread.started` ID while a one-shot call is still running:

```elixir
reference = make_ref()
CodexWrapper.exec("continue the work",
  session_observer: {observer_pid, reference},
  working_dir: "/path/to/project", timeout: 300_000)
```

The local observer receives
`{reference, %CodexWrapper.SessionObservation{session_id: id, source: :thread_started}}`.
The execution caller sends this message before returning, so its later terminal
reply to the same observer cannot overtake the observation. Callers own
persistence and must reject references from stale execution attempts. An
observed identity is not proof of completion; the call can still fail or time out.

The same option works with `Exec.execute/3`, `ExecResume.execute/3`,
`ExecFork.execute/3`, `ExecFork.fork/3`, their `execute_json/3` variants and
`CodexWrapper.exec_json/2`. It requires `Runner.Forcola` and Forcola in the
supported dependency range (`~> 0.12`). Invalid observer options and runners
without observed execution fail before spawning. A dead observer is harmless;
no caller callback runs and no silent Port fallback occurs.

Observed commands force `--json`. Their `Result` preserves every stdout and
stderr byte in separate fields, including final output without a newline.
Legacy `execute/2` and convenience calls without an observer retain merged
output. Exit codes and process success semantics stay the same. Fork identity
comes only from valid stdout `thread.started`, while stderr remains available
for failure diagnostics.

Malformed, blank, duplicate and conflicting later announcements are ignored.
Identity framing is limited to 1 MiB per line; an oversized line is skipped
until the next newline without truncating the final raw result. The whole-run
deadline, including Forcola's configured default for a nil timeout, stays in
force. Completion waits for transport termination even after `turn.completed`.
The execution caller owns and cleans up its helper; owner death closes the
Forcola port and triggers process-group cleanup.

### Evidenced one-shot execution and isolated configuration

`CodexWrapper.Config.new/1` snapshots the application runner and runner options only when
those fields are absent. Explicit `runner_options: []` overrides defaults;
concurrent calls can select different binaries, directories, environment,
models, CLI config/MCP overrides and transport requirements without changing
application or OS state. All one-shot commands, including `raw`, version,
completion and command convenience functions, honor the config's runner.
Directly constructed structs with nil runner fields retain legacy default lookup.
Forcola's legacy one-shot default timeout is captured by `CodexWrapper.Config.new/1` too.

The formerly `System.cmd` commands (auth login/logout/status, apply, completion,
features, MCP, mcp-server and version) now use this runner routing and its timeout
semantics. With the default Port runner they run through `/bin/sh`, with stdin
from `/dev/null` and the SIGINT/TERM/KILL watchdog; a missing binary returns
`{:error, {:exit, 127, output}}` instead of raising. Port has no whole-run bound
unless config `:timeout` is set. Forcola uses the snapshotted
`:forcola_default_timeout_ms` (300,000 ms unless configured) when no explicit
limit is set, then cancels the child and returns `{:error, {:timeout, milliseconds}}`.
Interactive login, device authorization and long-running mcp-server commands
therefore need an explicit config `:timeout` sufficient for the intended wait or
server lifetime under Forcola. This routing does not supply interactive stdin
or a duplex MCP connection.

Add `execution_observer` to `Exec.execute/3`, `ExecResume.execute/3`,
`ExecFork.execute/3`, `ExecFork.fork/3`, `Review.execute/3`, JSON variants,
or the exec/review convenience functions. Supply a `session_observer` too
when early native identity is needed:

```elixir
ref = make_ref()
config = CodexWrapper.Config.new(
  binary: "/opt/codex", working_dir: "/work/project",
  env: [{"CODEX_HOME", "/work/config-a"}, {"OPENAI_API_KEY", "caller-supplied"}],
  runner: CodexWrapper.Runner.Forcola,
  timeout: 60_000,
  runner_options: [shim_path: "/opt/forcola_shim", env_mode: :replace,
    idle_timeout_ms: 10_000, max_output_bytes: 1_048_576,
    interrupt_signal: :int, interrupt_grace_ms: 1_000, kill_grace_ms: 500])
CodexWrapper.Exec.execute(CodexWrapper.Exec.new("inspect the project"), config,
  session_observer: {self(), ref}, execution_observer: {self(), ref})
# {ref, %CodexWrapper.SessionObservation{...}}       (if announced)
# {ref, %CodexWrapper.ExecutionObservation{...}}     (exactly once before return)
```

The evidenced adapter requires Forcola `~> 0.12`, which supplies the `run/2`
evidence contract and matching shim. A Forcola without that contract is
rejected before evidenced execution. The example requires the matching trusted
shim; fake tests do not establish native behavior.

A terminal observation preserves bounded stdout/stderr prefixes (including on
failure), the last parsed `turn.completed` / `turn.failed` object, the legacy
outcome, raw child exit/signal, protocol/transport termination, cancellation
reason, truncation, cleanup confirmation/scope, and guard close independently.
Missing facts stay unknown. A session ID, JSON result, exit zero, or exhausted
enumerable does not establish clean completion. Process-group confirmation
says nothing about escaped descendants (Forcola #93 / Codex #119).

Without an explicit limit, evidenced execution uses a finite wall bound: Forcola
uses the snapshotted `:forcola_default_timeout_ms` (300,000 ms unless configured);
other runners use 300,000 ms. Idle defaults to 300,000 ms, with 1 MiB combined
stdout/stderr. `:timeout_ms` supplies a per-config
runner default; `:timeout` takes precedence. Idle/output limits and interrupt
grace must be positive 32-bit integers; kill grace may be zero. Interrupt
signal/grace must be supplied together and only `:int` is accepted. Replacement
environment clears inherited variables even with `env: []`; use an absolute
binary and supply every variable the target requires. Explicit shim paths must
be absolute; installation, matching version and lifetime belong to the caller.

Custom runners opt in with `run_evidenced/5` and may provide
`validate_evidence/0` for capability preflight. `validate_options/1` is required
for nonempty runner options. Unsupported requirements are errors before spawn,
with no Port fallback. A valid execution observer receives a terminal fact
on preflight failure too, without a fabricated child exit. Invalid execution
targets cannot receive a fact. The owned bounded collector forwards identity
from the caller, contains private raw messages, monitors caller/worker death,
and closes before the terminal fact. Forcola remains the native process owner;
there is no competing cancellation loop. Existing session-only observation
and legacy return shapes remain available.

The new controls are one-shot only; streams reject nonempty `runner_options`
and `execution_observer` before spawning. An explicitly selected per-config runner
(including a non-nil runner in a direct struct) must implement `stream_lines/4`;
otherwise stream consumption raises `ArgumentError` before spawning, with no Port
fallback. Implicit application/default runner selection retains the legacy Port
fallback with a warning when the streaming callback is unavailable.

The default `mix test` command includes the required contract suite and resolves
the matching shim from the installed Forcola dependency. To run it alone:

```sh
mix test test/codex_wrapper/runner/forcola_evidence_contract_test.exs
```

An optional `FORCOLA_SHIM_PATH=/absolute/path/to/matching/forcola_shim` test
override selects a trusted executable built for the same Forcola version.
This suite reports a missing contract or shim as a failure and is not excluded
by the legacy `:forcola` availability tag. Fixtures run without Codex credentials
and clean their temporary files; native process cleanup is asserted separately.

### Runners: process-group cleanup with forcola

Every `codex` subprocess -- the synchronous commands
(`CodexWrapper.exec/2`, `Exec.execute/2`, `ExecResume.execute/2`,
`Review.execute/2`) and the NDJSON streaming paths (`Exec.stream/2`,
`ExecResume.stream/2`, `Review.stream/2`, `Session.stream/3`) -- routes
through a runner module. The default, `CodexWrapper.Runner.Port`, runs `codex` under a
`/bin/sh` wrapper, supplies the prompt on stdin, and bounds synchronous execution
with a BEAM `Task` timeout. Commands without prompt input receive `/dev/null`.

When the port closes -- a timeout, a stream idle timeout or early halt, the
owning process dying, or the BEAM dying -- the wrapper's watchdog signals
the `codex` process: SIGINT first, SIGTERM two seconds later, SIGKILL one
second after that, stopping once the process is gone. Codex installs its own
SIGINT handler and uses it to stop tool children it placed in other process
groups. The shell starts `codex` as a background job, which begins with
SIGINT ignored, so a program without such a handler is stopped by the
SIGTERM step. The caller is not held up by this schedule.

This is not a process-tree guarantee. Only the `codex` process is signalled,
so a child that Codex does not stop itself, a daemonized process, or a stdio
MCP server can survive. A process that writes to the closed port can also die
of a broken pipe before its SIGINT handler finishes. Signals are sent by pid,
so a pid reused between the target's exit and the watchdog's cancellation
could be signalled by mistake; the window is kept short, not removed. The
schedule needs an executable `/bin/sleep` to escalate; without one only
SIGINT is sent.

The optional [`forcola`](https://hexdocs.pm/forcola) dependency provides
`CodexWrapper.Runner.Forcola`, which routes runs through a Rust shim that
puts `codex` in its own process group and signals the whole group on
timeout, on close, or when the BEAM dies. One-shot runs send SIGINT first,
SIGTERM after `interrupt_grace_ms` (default 2,000), then SIGKILL after
`kill_grace_ms`; set `interrupt_signal: :int` and `interrupt_grace_ms` in
`runner_options` to change the grace. The SIGINT step matters because codex
puts each tool command and stdio MCP server in its own process group: on
SIGINT codex stops the command it is running, while on SIGTERM alone it
exits and leaves that command behind.

Streaming runs through forcola follow the same schedule with the default
2,000 ms grace when a stream is halted, times out, or its owner or the BEAM
dies. Neither runner stops descendants that leave codex's process
group or ignore these signals. On Linux, forcola's cgroup option is the
backstop; on macOS there is none. `forcola` is POSIX-only (macOS and Linux)
and ships precompiled shim binaries, so no Rust toolchain is required.

```elixir
# mix.exs
{:forcola, "~> 0.12"}
```

```elixir
# config/config.exs
config :codex_wrapper, runner: CodexWrapper.Runner.Forcola
```

`:forcola` and `:task` work as shorthand for the two built-in runners.
`:forcola` falls back to the default runner when the dependency is
absent, so it is safe to set unconditionally:

```elixir
config :codex_wrapper, runner: :forcola
```

`CodexWrapper.Runner.Forcola` compiles only when `forcola` is present, so
the dependency stays optional and the default path is unchanged.
`forcola` requires a finite timeout, so when a command's `:timeout` is
`nil` the run falls back to `:forcola_default_timeout_ms` (default
`300_000`) instead of running unbounded for synchronous commands:

```elixir
config :codex_wrapper, runner: :forcola, forcola_default_timeout_ms: 120_000
```

#### Streaming through a runner

The streaming paths go through the same selection, so `:forcola` gets
their process group killed too -- on an early `Enum.take/2`, on a
timeout, or when the BEAM dies.

Both runners use `:timeout` as the whole-run deadline and
`:idle_timeout_ms` as the independent gap between output frames. A
Port stream has no whole-run deadline when `:timeout` is `nil`; a
Forcola stream uses `:forcola_default_stream_timeout_ms` (default
3,600,000 ms) instead. The existing `:forcola_default_timeout_ms`
setting also applies to streams unless the stream-specific setting is
provided. If either
bound expires, the stream emits a final `%CodexWrapper.StreamError{}`
with `{:timeout, ms}` or `{:idle_timeout, ms}` in its `reason` field.
Direct `Runner.stream_lines/4` consumers receive the corresponding
`{:error, reason}` item before the stream ends.

A run that ends badly ends its stream with one final
`%CodexWrapper.StreamError{}` after its last line, on either runner and
without raising: `reason` is `{:exit_status, code}` for a nonzero exit
(127 for a missing executable on `Runner.Port`), `{:spawn, reason}` when
the process could not be started, or `{:signal, signal}` when
`Runner.Forcola` reports death by signal. A zero exit adds nothing.

A custom runner only has to implement `run/4`; `stream_lines/4` is
optional, and a runner without it falls back to `Runner.Port` for
streams.

### Exec options

| Option | Type | Description |
|---|---|---|
| `:prompt_input` | `:stdin` or `:argv` | Prompt transport (default `:stdin`) |
| `:model` | `String.t()` | Model name (e.g., `"o3"`, `"o4-mini"`) |
| `:profile` | `String.t()` | Config profile: layers `$CODEX_HOME/<name>.config.toml` (`--profile`) |
| `:sandbox` | atom | `:read_only`, `:workspace_write`, or `:danger_full_access` |
| `:approval_policy` | atom | `:untrusted`, `:on_request`, or `:never` |
| `:full_auto` | `boolean()` | Enable full-auto mode |
| `:cd` | `String.t()` | Working directory for codex subprocess |
| `:skip_git_repo_check` | `boolean()` | Skip git repository check |
| `:search` | `boolean()` or atom | `true` for live search, or `:cached`, `:indexed`, `:live`, `:disabled` |
| `:ephemeral` | `boolean()` | Disable session persistence |
| `:json` | `boolean()` | Enable JSON (NDJSON) output |
| `:output_schema` | `String.t()` | Path to output schema file |
| `:output_last_message` | `String.t()` | Path to save last message |
| `:strict_config` | `boolean()` | Fail on unrecognized `config.toml` keys |
| `:ignore_user_config` | `boolean()` | Skip `$CODEX_HOME/config.toml` |
| `:ignore_rules` | `boolean()` | Skip execpolicy `.rules` files |
| `:dangerously_bypass_hook_trust` | `boolean()` | Run hooks without persisted trust |
| `:color` | atom | `:always`, `:never`, or `:auto` |
| `:oss` | `boolean()` | Use an open-source provider |
| `:local_provider` | `String.t()` | `"lmstudio"` or `"ollama"` |
| `:add_dir` | `String.t()` or list | Make an extra directory writable (`--add-dir`) |
| `:image` | `String.t()` or list | Image to attach (Exec and resumed turns) |
| `:config` | `String.t()` or list | `key=value` config override (`-c`) |
| `:enable` / `:disable` | `String.t()` or list | Feature flag to enable or disable |
| `:approve_for_me` | `boolean()` | Route approval requests through automatic review; on Exec not with `:sandbox`, `:full_auto` or the bypass flag |
| `:worktree` | `boolean()` | Run in a new codex-managed git worktree; not with `:ephemeral`; codex-cli 0.157.1+ (first session turn only) |
| `:thread_source` | `String.t()` | Source classification for a new thread; codex-cli 0.157.1+ (first session turn only) |
| `:cyber_access_program` | atom | `:standard`, `:daybreak_blue` or `:daybreak_red`; OpenAI provider only; codex-cli 0.162.0+ |

`CodexWrapper.exec/2`, `exec_json/2`, `stream/2`, `review/1` and
`CodexWrapper.Session` raise `ArgumentError` on an option not listed for
them, and on a value of the wrong type. `review/1` takes the review target
options listed in its docs plus every option in this table except
`:image`, `:worktree` and `:cyber_access_program`.

## Modules

| Module | Description |
|---|---|
| `CodexWrapper` | Convenience API for exec, review, stream, and version |
| `CodexWrapper.Config` | Shared client configuration and binary discovery |
| `CodexWrapper.Exec` | Exec command builder with fluent API |
| `CodexWrapper.ExecResume` | Session resume/continue builder |
| `CodexWrapper.ExecFork` | Non-interactive session fork builder |
| `CodexWrapper.UnsupportedError` | Raised by a stream when the CLI lacks a capability |
| `CodexWrapper.Review` | Code review builder |
| `CodexWrapper.Result` | Parsed command result (stdout, stderr, exit code) |
| `CodexWrapper.JsonLineEvent` | NDJSON streaming event parser |
| `CodexWrapper.Session` | Multi-turn session management |
| `CodexWrapper.SessionServer` | GenServer wrapper for sessions |
| `CodexWrapper.Retry` | Exponential backoff retry |
| `CodexWrapper.IEx` | Interactive REPL helpers |
| `CodexWrapper.Telemetry` | `:telemetry` span wrappers for exec paths |
| `CodexWrapper.Command` | Behaviour for CLI commands |
| `CodexWrapper.Runner` | Behaviour selecting how subprocesses execute and stream |
| `CodexWrapper.Runner.Port` | Default runner (`/bin/sh` Port with prompt stdin and INT/TERM/KILL cleanup of `codex`) |
| `CodexWrapper.Runner.Forcola` | Optional leak-free runner via `forcola` |
| `CodexWrapper.Commands.Auth` | Authentication (login/logout/status) |
| `CodexWrapper.Commands.Features` | Feature flag management |
| `CodexWrapper.Commands.Mcp` | MCP server CRUD |
| `CodexWrapper.Commands.McpServer` | Run Codex as an MCP server |
| `CodexWrapper.Commands.Sandbox` | Sandboxed command execution |
| `CodexWrapper.Commands.Apply` | Apply diffs from task IDs |
| `CodexWrapper.Commands.Archive` | Archive, unarchive, and delete saved sessions |
| `CodexWrapper.Commands.Plugin` | Plugins and plugin marketplaces |
| `CodexWrapper.Commands.Queue` | Queue a message for an existing session |
| `CodexWrapper.History` | Read session rollout files, plain and `.jsonl.zst` |
| `CodexWrapper.Commands.Completion` | Shell completion script generation |
| `CodexWrapper.Commands.Doctor` | Install, config, auth, and runtime diagnostics |
| `CodexWrapper.Commands.Version` | CLI version |

## Testing

The default suite does not invoke an authenticated Codex session:

```bash
mix test
```

Opt-in integration tests exercise the installed Codex CLI, including its
live JSON event shape, session/thread identifier, feature-list format,
and streaming Port path. They require `codex` on `PATH` and an active
login:

```bash
mix test --include integration
# or only the live checks
mix test --only integration
```

The separate CLI contract check is authentication-free and verifies that
the flags and config keys emitted by the wrapper are still accepted:

```bash
mix codex.contract
```

## License

MIT -- see [LICENSE](LICENSE).
