Current section
Files
Jump to
Current section
Files
lib/codex/cli/session.ex
defmodule Codex.CLI.Session do
@moduledoc """
Raw `codex` CLI subprocess session.
Sessions are useful for interactive or long-running commands such as:
- `codex`
- `codex resume`
- `codex fork`
- `codex app-server`
- `codex mcp-server`
The caller process receives raw `erlexec` messages for the spawned process:
- `{:stdout, os_pid, binary}`
- `{:stderr, os_pid, binary}`
- `{:DOWN, os_pid, :process, pid, reason}`
Use `collect/2` to accumulate output until the process exits.
"""
alias Codex.Config.Defaults
alias Codex.Runtime.Erlexec
@enforce_keys [:args, :command, :os_pid, :pid, :receiver]
defstruct [:args, :command, :os_pid, :pid, :receiver, pty?: false, stdin?: false]
@type t :: %__MODULE__{
args: [String.t()],
command: [String.t()],
os_pid: non_neg_integer(),
pid: pid(),
receiver: pid(),
pty?: boolean(),
stdin?: boolean()
}
@type result :: %{
command: [String.t()],
args: [String.t()],
stdout: String.t(),
stderr: String.t(),
exit_code: integer(),
success: boolean()
}
@doc """
Starts a raw subprocess session for `binary_path` and `args`.
"""
@spec start(String.t(), [String.t()], keyword()) :: {:ok, t()} | {:error, term()}
def start(binary_path, args, opts \\ [])
when is_binary(binary_path) and is_list(args) and is_list(opts) do
receiver = Keyword.get(opts, :receiver, self())
pty? = Keyword.get(opts, :pty, false)
stdin? = Keyword.get(opts, :stdin, false)
with :ok <- Erlexec.ensure_started(),
exec_opts <- build_exec_opts(receiver, opts, pty?, stdin?),
argv <- normalize_argv(binary_path, args),
{:ok, pid, os_pid} <- exec_run(argv, exec_opts) do
{:ok,
%__MODULE__{
args: args,
command: [binary_path | args],
os_pid: os_pid,
pid: pid,
receiver: receiver,
pty?: pty?,
stdin?: stdin?
}}
end
end
@doc """
Sends input bytes to the subprocess stdin.
"""
@spec send_input(t(), iodata()) :: :ok | {:error, term()}
def send_input(%__MODULE__{stdin?: false}, _data), do: {:error, :stdin_unavailable}
def send_input(%__MODULE__{pid: pid}, data) do
:exec.send(pid, IO.iodata_to_binary(data))
catch
:exit, reason -> {:error, reason}
end
@doc """
Closes the subprocess stdin by sending EOF.
"""
@spec close_input(t()) :: :ok | {:error, term()}
def close_input(%__MODULE__{stdin?: false}), do: {:error, :stdin_unavailable}
def close_input(%__MODULE__{pid: pid}) do
:exec.send(pid, :eof)
catch
:exit, reason -> {:error, reason}
end
@doc """
Stops the subprocess.
"""
@spec stop(t()) :: :ok | {:error, term()}
def stop(%__MODULE__{pid: pid}) do
:exec.stop(pid)
catch
:exit, reason -> {:error, reason}
end
@doc """
Interrupts the subprocess.
"""
@spec interrupt(t()) :: :ok | {:error, term()}
def interrupt(%__MODULE__{pid: pid, pty?: true}) do
:exec.send(pid, <<3>>)
catch
:exit, reason -> {:error, reason}
end
def interrupt(%__MODULE__{} = session), do: stop(session)
@doc """
Collects stdout/stderr until the subprocess exits.
"""
@spec collect(t(), timeout()) :: {:ok, result()} | {:error, term()}
def collect(%__MODULE__{} = session, timeout_ms \\ Defaults.exec_timeout_ms()) do
do_collect(session, timeout_ms, [], [])
end
defp do_collect(%__MODULE__{os_pid: os_pid, pid: pid} = session, timeout_ms, stdout, stderr) do
receive do
{:stdout, ^os_pid, data} ->
do_collect(session, timeout_ms, [data | stdout], stderr)
{:stderr, ^os_pid, data} ->
do_collect(session, timeout_ms, stdout, [data | stderr])
{:DOWN, ^os_pid, :process, ^pid, reason} ->
exit_code = exit_code(reason)
{:ok,
%{
command: session.command,
args: session.args,
stdout: stdout |> Enum.reverse() |> IO.iodata_to_binary(),
stderr: stderr |> Enum.reverse() |> IO.iodata_to_binary(),
exit_code: exit_code,
success: exit_code == 0
}}
after
timeout_ms ->
{:error, {:timeout, session}}
end
end
defp build_exec_opts(receiver, opts, pty?, stdin?) do
[]
|> maybe_put_cd(Keyword.get(opts, :cwd))
|> maybe_put_env(Keyword.get(opts, :env, []))
|> maybe_put_stdin(stdin?)
|> maybe_put_pty(pty?)
|> Kernel.++([{:stdout, receiver}, {:stderr, receiver}, :monitor])
end
defp maybe_put_cd(exec_opts, nil), do: exec_opts
defp maybe_put_cd(exec_opts, cwd), do: [{:cd, to_charlist(cwd)} | exec_opts]
defp maybe_put_env(exec_opts, []), do: exec_opts
defp maybe_put_env(exec_opts, nil), do: exec_opts
defp maybe_put_env(exec_opts, env), do: [{:env, env} | exec_opts]
defp maybe_put_stdin(exec_opts, true), do: [:stdin | exec_opts]
defp maybe_put_stdin(exec_opts, _), do: exec_opts
defp maybe_put_pty(exec_opts, true), do: [:pty | exec_opts]
defp maybe_put_pty(exec_opts, _), do: exec_opts
defp normalize_argv(binary_path, args) do
[binary_path | Enum.map(args, &to_string/1)]
|> Enum.map(&to_charlist/1)
end
defp exec_run(argv, exec_opts) do
:exec.run(argv, exec_opts)
catch
:exit, reason -> {:error, reason}
end
defp exit_code(:normal), do: 0
defp exit_code({:exit_status, raw_status}) when is_integer(raw_status) do
normalize_exit_status(raw_status)
end
defp exit_code(raw_status) when is_integer(raw_status) do
normalize_exit_status(raw_status)
end
defp exit_code(_reason), do: -1
defp normalize_exit_status(raw_status) when is_integer(raw_status) do
case :exec.status(raw_status) do
{:status, code} -> code
{:signal, signal, _core?} -> 128 + :exec.signal_to_int(signal)
end
rescue
_ -> raw_status
end
end