diff --git a/README.md b/README.md
index 454882d..3e98e8b 100644
--- a/README.md
+++ b/README.md
@@ -290,6 +290,23 @@ 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:
diff --git a/docs/last-message-native-0.165.0.md b/docs/last-message-native-0.165.0.md
new file mode 100644
index 0000000..d877be9
--- /dev/null
+++ b/docs/last-message-native-0.165.0.md
@@ -0,0 +1,15 @@
+# Last-message native probe
+
+Verified by the operator on macOS, 2026-11-01, with codex-cli 0.165.0.
+Run outside any git repository:
+
+```sh
+codex exec --ephemeral --ignore-user-config --skip-git-repo-check \
+ --model codex-wrapper-nonexistent-model -o
/last.txt 'Reply with one word.'
+```
+
+The CLI started a session, warned that model metadata was not found, and
+printed twice that the model is not supported when using Codex with a
+ChatGPT account. It exited 1 without creating the `-o` file. With the
+wrapper's pre-created owned file, the file remains empty and the returned
+`{:process_failed, text, result}` carries `""` as its text.
diff --git a/lib/codex_wrapper/last_message.ex b/lib/codex_wrapper/last_message.ex
new file mode 100644
index 0000000..1bc93d8
--- /dev/null
+++ b/lib/codex_wrapper/last_message.ex
@@ -0,0 +1,102 @@
+defmodule CodexWrapper.LastMessage do
+ @moduledoc """
+ Run a command and read its final agent message from `--output-last-message`.
+
+ {:ok, text, result} =
+ CodexWrapper.Exec.new("Summarize the diff in one paragraph")
+ |> CodexWrapper.LastMessage.run(CodexWrapper.Config.new())
+
+ Supports `Exec`, `ExecResume`, `ExecFork` and `Review`. An explicit
+ `output_last_message/2` path is used as supplied and remains the caller's
+ responsibility. Otherwise this module owns a temporary file for the run.
+ Text is returned verbatim, independently of stdout and stderr.
+
+ A zero exit with a missing or empty file returns
+ `{:error, {:no_last_message, result}}`. A non-zero exit returns
+ `{:error, {:process_failed, text, result}}`, with `""` for a missing file.
+ With codex-cli 0.165.0 a failed turn exits non-zero without writing the file,
+ so an owned file is left empty and the text in `{:process_failed, text, result}` is `""`.
+ Other read failures return `{:error, {:last_message_read, reason, result}}`.
+ File creation failures return `{:error, {:last_message_file, reason}}`;
+ runner errors are returned unchanged.
+ """
+
+ alias CodexWrapper.{Config, Exec, ExecFork, ExecResume, Result, Review}
+
+ @type command :: Exec.t() | ExecResume.t() | ExecFork.t() | Review.t()
+ @type error ::
+ {:no_last_message, Result.t()}
+ | {:process_failed, String.t(), Result.t()}
+ | {:last_message_read, File.posix(), Result.t()}
+ | {:last_message_file, File.posix()}
+ | term()
+
+ @doc """
+ Run `command` and return its final agent message and original result.
+
+ Options are passed to the command's `execute/3`, including observation
+ options. The owned file is created with mode `0600` in `System.tmp_dir!/0`
+ and removed after execution on success, failure or raise. Explicit paths
+ are neither created ahead of execution nor removed by this module.
+ """
+ @spec run(command(), Config.t(), keyword()) ::
+ {:ok, String.t(), Result.t()} | {:error, error()}
+ def run(command, %Config{} = config, opts \\ []) when is_list(opts) do
+ module = command_module(command)
+
+ case command.output_last_message do
+ nil -> with_message_file(fn path -> execute(module, command, config, opts, path) end)
+ path -> execute(module, command, config, opts, path)
+ end
+ end
+
+ defp command_module(%Exec{}), do: Exec
+ defp command_module(%ExecResume{}), do: ExecResume
+ defp command_module(%ExecFork{}), do: ExecFork
+ defp command_module(%Review{}), do: Review
+
+ defp execute(module, command, config, opts, path) do
+ command
+ |> module.output_last_message(path)
+ |> module.execute(config, opts)
+ |> case do
+ {:ok, %Result{} = result} -> read_message(path, result)
+ {:error, _reason} = error -> error
+ end
+ end
+
+ defp read_message(path, result) do
+ case File.read(path) do
+ {:ok, text} -> outcome(text, result)
+ {:error, :enoent} -> outcome("", result)
+ {:error, reason} -> {:error, {:last_message_read, reason, result}}
+ end
+ end
+
+ defp outcome(text, %Result{exit_code: code} = result) when code != 0,
+ do: {:error, {:process_failed, text, result}}
+
+ defp outcome("", result), do: {:error, {:no_last_message, result}}
+ defp outcome(text, result), do: {:ok, text, result}
+
+ defp with_message_file(fun) do
+ path =
+ Path.join(
+ System.tmp_dir!(),
+ "codex-wrapper-last-message-#{System.pid()}-#{System.unique_integer([:positive])}.txt"
+ )
+
+ with :ok <- File.write(path, "", [:exclusive]),
+ :ok <- File.chmod(path, 0o600) do
+ try do
+ fun.(path)
+ after
+ File.rm(path)
+ end
+ else
+ {:error, reason} ->
+ File.rm(path)
+ {:error, {:last_message_file, reason}}
+ end
+ end
+end
diff --git a/mix.exs b/mix.exs
index a332200..583d89b 100644
--- a/mix.exs
+++ b/mix.exs
@@ -61,6 +61,7 @@ defmodule CodexWrapperEx.MixProject do
"Config, results, support": [
CodexWrapper.Config,
CodexWrapper.Result,
+ CodexWrapper.LastMessage,
CodexWrapper.SessionObservation,
CodexWrapper.ObservedExecution,
CodexWrapper.JsonLineEvent,
diff --git a/test/codex_wrapper/last_message_test.exs b/test/codex_wrapper/last_message_test.exs
new file mode 100644
index 0000000..d79869f
--- /dev/null
+++ b/test/codex_wrapper/last_message_test.exs
@@ -0,0 +1,141 @@
+defmodule CodexWrapper.LastMessageTest do
+ use ExUnit.Case, async: true
+
+ alias CodexWrapper.{Config, Exec, ExecFork, ExecResume, LastMessage, Result, Review}
+
+ @fake Path.expand("../fixtures/fake_codex_last_message", __DIR__)
+
+ defmodule InspectRunner do
+ def run(mode, args, _opts, _timeout) do
+ index = Enum.find_index(args, &(&1 == "--output-last-message"))
+ path = Enum.at(args, index + 1)
+ send(self(), {:path, path, File.stat!(path)})
+
+ case mode do
+ "raise" ->
+ raise "runner failed"
+
+ "error" ->
+ {:error, :synthetic}
+
+ "read_error" ->
+ {:ok, {"noise", 0}}
+
+ "ok" ->
+ File.write!(path, "private message")
+ {:ok, {"noise", 0}}
+ end
+ end
+
+ def run_observed(mode, args, opts, timeout, {caller, reference}) do
+ unless "--json" in args, do: raise("expected observed execution")
+ send(caller, {reference, {:stdout, ~s({"type":"thread.started","thread_id":"fake"}\n)}})
+ {:ok, {stdout, code}} = run(mode, args, opts, timeout)
+ {:ok, {stdout, code, "separate stderr"}}
+ end
+ end
+
+ defp run_fake(scenario, command \\ Exec.new("summarize")) do
+ calls =
+ Path.join(System.tmp_dir!(), "last-message-calls-#{System.unique_integer([:positive])}")
+
+ on_exit(fn -> File.rm(calls) end)
+
+ config =
+ Config.new(
+ binary: @fake,
+ timeout: 10_000,
+ env: [{"FAKE_CODEX_CALLS", calls}, {"FAKE_CODEX_SCENARIO", scenario}]
+ )
+
+ outcome = LastMessage.run(command, config)
+ path = calls |> File.read!() |> String.trim_trailing()
+ {outcome, path}
+ end
+
+ test "returns exact final text for all four commands and removes owned files" do
+ for command <- [Exec.new("x"), ExecResume.new(), ExecFork.new("source-thread"), Review.new()] do
+ assert {{:ok, "final message\n", %Result{exit_code: 0} = result}, path} =
+ run_fake("success", command)
+
+ assert result.stdout =~ "stdout noise"
+ assert result.stdout =~ "stderr noise"
+ refute File.exists?(path)
+ end
+ end
+
+ test "missing and empty successful output is no_last_message" do
+ for scenario <- ["missing", "empty"] do
+ assert {{:error, {:no_last_message, %Result{exit_code: 0}}}, path} = run_fake(scenario)
+ refute File.exists?(path)
+ end
+ end
+
+ test "nonzero exits retain text and result, including missing and empty files" do
+ for {scenario, text} <- [
+ {"failed", "final message\n"},
+ {"failed_missing", ""},
+ {"failed_empty", ""}
+ ] do
+ assert {{:error, {:process_failed, ^text, %Result{exit_code: 2}}}, path} =
+ run_fake(scenario)
+
+ refute File.exists?(path)
+ end
+ end
+
+ test "explicit paths remain the caller's for every command" do
+ path = Path.join(System.tmp_dir!(), "caller-message-#{System.unique_integer([:positive])}")
+ on_exit(fn -> File.rm(path) end)
+
+ for command <- [Exec.new("x"), ExecResume.new(), ExecFork.new("source-thread"), Review.new()] do
+ command = %{command | output_last_message: path}
+ assert {{:ok, "final message\n", _result}, ^path} = run_fake("success", command)
+ assert File.read!(path) == "final message\n"
+ end
+ end
+
+ test "passes observation options through and preserves the result" do
+ config = Config.new(binary: "ok", runner: InspectRunner)
+ reference = make_ref()
+
+ assert {:ok, "private message", %Result{stdout: "noise", stderr: "separate stderr"}} =
+ LastMessage.run(Exec.new("x"), config, session_observer: {self(), reference})
+
+ assert_received {^reference, %CodexWrapper.SessionObservation{session_id: "fake"}}
+ end
+
+ test "read errors retain the original result and explicit path" do
+ config = Config.new(binary: "read_error", runner: InspectRunner)
+ command = Exec.new("x") |> Exec.output_last_message(__DIR__)
+
+ assert {:error, {:last_message_read, :eisdir, %Result{exit_code: 0}}} =
+ LastMessage.run(command, config)
+
+ assert File.dir?(__DIR__)
+ end
+
+ test "owned file is private and cleaned on success, runner errors and raises" do
+ for mode <- ["ok", "error", "raise"] do
+ config = Config.new(binary: mode, runner: InspectRunner)
+
+ case mode do
+ "ok" ->
+ assert {:ok, "private message", _} = LastMessage.run(Exec.new("x"), config)
+
+ "error" ->
+ assert {:error, :synthetic} = LastMessage.run(Exec.new("x"), config)
+
+ "raise" ->
+ assert_raise RuntimeError, "runner failed", fn ->
+ LastMessage.run(Exec.new("x"), config)
+ end
+ end
+
+ assert_received {:path, path, stat}
+ assert Bitwise.band(stat.mode, 0o777) == 0o600
+ assert Path.dirname(path) == Path.expand(System.tmp_dir!())
+ refute File.exists?(path)
+ end
+ end
+end
diff --git a/test/fixtures/fake_codex_last_message b/test/fixtures/fake_codex_last_message
new file mode 100755
index 0000000..e323ebc
--- /dev/null
+++ b/test/fixtures/fake_codex_last_message
@@ -0,0 +1,19 @@
+#!/bin/sh
+previous=''
+for arg in "$@"; do
+ if [ "$previous" = '--output-last-message' ] || [ "$previous" = '-o' ]; then
+ path="$arg"
+ fi
+ previous="$arg"
+done
+printf '%s\n' "$path" > "$FAKE_CODEX_CALLS"
+case "$FAKE_CODEX_SCENARIO" in
+ missing|failed_missing) rm -f "$path" ;;
+ empty|failed_empty) : > "$path" ;;
+ *) printf 'final message\n' > "$path" ;;
+esac
+printf 'stdout noise\n'
+printf 'stderr noise\n' >&2
+case "$FAKE_CODEX_SCENARIO" in
+ failed*) exit 2 ;;
+esac